Version 1
[yaffs-website] / vendor / caxy / php-htmldiff / lib / Caxy / HtmlDiff / HtmlDiff.php
diff --git a/vendor/caxy/php-htmldiff/lib/Caxy/HtmlDiff/HtmlDiff.php b/vendor/caxy/php-htmldiff/lib/Caxy/HtmlDiff/HtmlDiff.php
new file mode 100644 (file)
index 0000000..067ea98
--- /dev/null
@@ -0,0 +1,900 @@
+<?php
+
+namespace Caxy\HtmlDiff;
+
+use Caxy\HtmlDiff\Table\TableDiff;
+
+/**
+ * Class HtmlDiff.
+ */
+class HtmlDiff extends AbstractDiff
+{
+    /**
+     * @var array
+     */
+    protected $wordIndices;
+    /**
+     * @var array
+     */
+    protected $oldTables;
+    /**
+     * @var array
+     */
+    protected $newTables;
+    /**
+     * @var array
+     */
+    protected $newIsolatedDiffTags;
+    /**
+     * @var array
+     */
+    protected $oldIsolatedDiffTags;
+
+    /**
+     * @param string              $oldText
+     * @param string              $newText
+     * @param HtmlDiffConfig|null $config
+     *
+     * @return self
+     */
+    public static function create($oldText, $newText, HtmlDiffConfig $config = null)
+    {
+        $diff = new self($oldText, $newText);
+
+        if (null !== $config) {
+            $diff->setConfig($config);
+        }
+
+        return $diff;
+    }
+
+    /**
+     * @param $bool
+     *
+     * @return $this
+     *
+     * @deprecated since 0.1.0
+     */
+    public function setUseTableDiffing($bool)
+    {
+        $this->config->setUseTableDiffing($bool);
+
+        return $this;
+    }
+
+    /**
+     * @param bool $boolean
+     *
+     * @return HtmlDiff
+     *
+     * @deprecated since 0.1.0
+     */
+    public function setInsertSpaceInReplace($boolean)
+    {
+        $this->config->setInsertSpaceInReplace($boolean);
+
+        return $this;
+    }
+
+    /**
+     * @return bool
+     *
+     * @deprecated since 0.1.0
+     */
+    public function getInsertSpaceInReplace()
+    {
+        return $this->config->isInsertSpaceInReplace();
+    }
+
+    /**
+     * @return string
+     */
+    public function build()
+    {
+        $this->prepare();
+
+        if ($this->hasDiffCache() && $this->getDiffCache()->contains($this->oldText, $this->newText)) {
+            $this->content = $this->getDiffCache()->fetch($this->oldText, $this->newText);
+
+            return $this->content;
+        }
+
+        // Pre-processing Optimizations
+
+        // 1. Equality
+        if ($this->oldText == $this->newText) {
+            return $this->newText;
+        }
+
+        $this->splitInputsToWords();
+        $this->replaceIsolatedDiffTags();
+        $this->indexNewWords();
+
+        $operations = $this->operations();
+
+        foreach ($operations as $item) {
+            $this->performOperation($item);
+        }
+
+        if ($this->hasDiffCache()) {
+            $this->getDiffCache()->save($this->oldText, $this->newText, $this->content);
+        }
+
+        return $this->content;
+    }
+
+    protected function indexNewWords()
+    {
+        $this->wordIndices = array();
+        foreach ($this->newWords as $i => $word) {
+            if ($this->isTag($word)) {
+                $word = $this->stripTagAttributes($word);
+            }
+            if (isset($this->wordIndices[ $word ])) {
+                $this->wordIndices[ $word ][] = $i;
+            } else {
+                $this->wordIndices[ $word ] = array($i);
+            }
+        }
+    }
+
+    protected function replaceIsolatedDiffTags()
+    {
+        $this->oldIsolatedDiffTags = $this->createIsolatedDiffTagPlaceholders($this->oldWords);
+        $this->newIsolatedDiffTags = $this->createIsolatedDiffTagPlaceholders($this->newWords);
+    }
+
+    /**
+     * @param array $words
+     *
+     * @return array
+     */
+    protected function createIsolatedDiffTagPlaceholders(&$words)
+    {
+        $openIsolatedDiffTags = 0;
+        $isolatedDiffTagIndices = array();
+        $isolatedDiffTagStart = 0;
+        $currentIsolatedDiffTag = null;
+        foreach ($words as $index => $word) {
+            $openIsolatedDiffTag = $this->isOpeningIsolatedDiffTag($word, $currentIsolatedDiffTag);
+            if ($openIsolatedDiffTag) {
+                if ($this->isSelfClosingTag($word) || stripos($word, '<img') !== false) {
+                    if ($openIsolatedDiffTags === 0) {
+                        $isolatedDiffTagIndices[] = array(
+                            'start' => $index,
+                            'length' => 1,
+                            'tagType' => $openIsolatedDiffTag,
+                        );
+                        $currentIsolatedDiffTag = null;
+                    }
+                } else {
+                    if ($openIsolatedDiffTags === 0) {
+                        $isolatedDiffTagStart = $index;
+                    }
+                    ++$openIsolatedDiffTags;
+                    $currentIsolatedDiffTag = $openIsolatedDiffTag;
+                }
+            } elseif ($openIsolatedDiffTags > 0 && $this->isClosingIsolatedDiffTag($word, $currentIsolatedDiffTag)) {
+                --$openIsolatedDiffTags;
+                if ($openIsolatedDiffTags == 0) {
+                    $isolatedDiffTagIndices[] = array('start' => $isolatedDiffTagStart, 'length' => $index - $isolatedDiffTagStart + 1, 'tagType' => $currentIsolatedDiffTag);
+                    $currentIsolatedDiffTag = null;
+                }
+            }
+        }
+        $isolatedDiffTagScript = array();
+        $offset = 0;
+        foreach ($isolatedDiffTagIndices as $isolatedDiffTagIndex) {
+            $start = $isolatedDiffTagIndex['start'] - $offset;
+            $placeholderString = $this->config->getIsolatedDiffTagPlaceholder($isolatedDiffTagIndex['tagType']);
+            $isolatedDiffTagScript[$start] = array_splice($words, $start, $isolatedDiffTagIndex['length'], $placeholderString);
+            $offset += $isolatedDiffTagIndex['length'] - 1;
+        }
+
+        return $isolatedDiffTagScript;
+    }
+
+    /**
+     * @param string      $item
+     * @param null|string $currentIsolatedDiffTag
+     *
+     * @return false|string
+     */
+    protected function isOpeningIsolatedDiffTag($item, $currentIsolatedDiffTag = null)
+    {
+        $tagsToMatch = $currentIsolatedDiffTag !== null
+            ? array($currentIsolatedDiffTag => $this->config->getIsolatedDiffTagPlaceholder($currentIsolatedDiffTag))
+            : $this->config->getIsolatedDiffTags();
+        $pattern = '#<%s(\s+[^>]*)?>#iU';
+        foreach ($tagsToMatch as $key => $value) {
+            if (preg_match(sprintf($pattern, $key), $item)) {
+                return $key;
+            }
+        }
+
+        return false;
+    }
+
+    protected function isSelfClosingTag($text)
+    {
+        return (bool) preg_match('/<[^>]+\/\s*>/', $text);
+    }
+
+    /**
+     * @param string      $item
+     * @param null|string $currentIsolatedDiffTag
+     *
+     * @return false|string
+     */
+    protected function isClosingIsolatedDiffTag($item, $currentIsolatedDiffTag = null)
+    {
+        $tagsToMatch = $currentIsolatedDiffTag !== null
+            ? array($currentIsolatedDiffTag => $this->config->getIsolatedDiffTagPlaceholder($currentIsolatedDiffTag))
+            : $this->config->getIsolatedDiffTags();
+        $pattern = '#</%s(\s+[^>]*)?>#iU';
+        foreach ($tagsToMatch as $key => $value) {
+            if (preg_match(sprintf($pattern, $key), $item)) {
+                return $key;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * @param Operation $operation
+     */
+    protected function performOperation($operation)
+    {
+        switch ($operation->action) {
+            case 'equal' :
+            $this->processEqualOperation($operation);
+            break;
+            case 'delete' :
+            $this->processDeleteOperation($operation, 'diffdel');
+            break;
+            case 'insert' :
+            $this->processInsertOperation($operation, 'diffins');
+            break;
+            case 'replace':
+            $this->processReplaceOperation($operation);
+            break;
+            default:
+            break;
+        }
+    }
+
+    /**
+     * @param Operation $operation
+     */
+    protected function processReplaceOperation($operation)
+    {
+        $this->processDeleteOperation($operation, 'diffmod');
+        $this->processInsertOperation($operation, 'diffmod');
+    }
+
+    /**
+     * @param Operation $operation
+     * @param string    $cssClass
+     */
+    protected function processInsertOperation($operation, $cssClass)
+    {
+        $text = array();
+        foreach ($this->newWords as $pos => $s) {
+            if ($pos >= $operation->startInNew && $pos < $operation->endInNew) {
+                if ($this->config->isIsolatedDiffTagPlaceholder($s) && isset($this->newIsolatedDiffTags[$pos])) {
+                    foreach ($this->newIsolatedDiffTags[$pos] as $word) {
+                        $text[] = $word;
+                    }
+                } else {
+                    $text[] = $s;
+                }
+            }
+        }
+        $this->insertTag('ins', $cssClass, $text);
+    }
+
+    /**
+     * @param Operation $operation
+     * @param string    $cssClass
+     */
+    protected function processDeleteOperation($operation, $cssClass)
+    {
+        $text = array();
+        foreach ($this->oldWords as $pos => $s) {
+            if ($pos >= $operation->startInOld && $pos < $operation->endInOld) {
+                if ($this->config->isIsolatedDiffTagPlaceholder($s) && isset($this->oldIsolatedDiffTags[$pos])) {
+                    foreach ($this->oldIsolatedDiffTags[$pos] as $word) {
+                        $text[] = $word;
+                    }
+                } else {
+                    $text[] = $s;
+                }
+            }
+        }
+        $this->insertTag('del', $cssClass, $text);
+    }
+
+    /**
+     * @param Operation $operation
+     * @param int       $pos
+     * @param string    $placeholder
+     * @param bool      $stripWrappingTags
+     *
+     * @return string
+     */
+    protected function diffIsolatedPlaceholder($operation, $pos, $placeholder, $stripWrappingTags = true)
+    {
+        $oldText = implode('', $this->findIsolatedDiffTagsInOld($operation, $pos));
+        $newText = implode('', $this->newIsolatedDiffTags[$pos]);
+
+        if ($this->isListPlaceholder($placeholder)) {
+            return $this->diffList($oldText, $newText);
+        } elseif ($this->config->isUseTableDiffing() && $this->isTablePlaceholder($placeholder)) {
+            return $this->diffTables($oldText, $newText);
+        } elseif ($this->isLinkPlaceholder($placeholder)) {
+            return $this->diffElementsByAttribute($oldText, $newText, 'href', 'a');
+        } elseif ($this->isImagePlaceholder($placeholder)) {
+            return $this->diffElementsByAttribute($oldText, $newText, 'src', 'img');
+        }
+
+        return $this->diffElements($oldText, $newText, $stripWrappingTags);
+    }
+
+    /**
+     * @param string $oldText
+     * @param string $newText
+     * @param bool   $stripWrappingTags
+     *
+     * @return string
+     */
+    protected function diffElements($oldText, $newText, $stripWrappingTags = true)
+    {
+        $wrapStart = '';
+        $wrapEnd = '';
+
+        if ($stripWrappingTags) {
+            $pattern = '/(^<[^>]+>)|(<\/[^>]+>$)/i';
+            $matches = array();
+
+            if (preg_match_all($pattern, $newText, $matches)) {
+                $wrapStart = isset($matches[0][0]) ? $matches[0][0] : '';
+                $wrapEnd = isset($matches[0][1]) ? $matches[0][1] : '';
+            }
+            $oldText = preg_replace($pattern, '', $oldText);
+            $newText = preg_replace($pattern, '', $newText);
+        }
+
+        $diff = self::create($oldText, $newText, $this->config);
+
+        return $wrapStart.$diff->build().$wrapEnd;
+    }
+
+    /**
+     * @param string $oldText
+     * @param string $newText
+     *
+     * @return string
+     */
+    protected function diffList($oldText, $newText)
+    {
+        $diff = ListDiffLines::create($oldText, $newText, $this->config);
+
+        return $diff->build();
+    }
+
+    /**
+     * @param string $oldText
+     * @param string $newText
+     *
+     * @return string
+     */
+    protected function diffTables($oldText, $newText)
+    {
+        $diff = TableDiff::create($oldText, $newText, $this->config);
+
+        return $diff->build();
+    }
+
+    protected function diffElementsByAttribute($oldText, $newText, $attribute, $element)
+    {
+        $oldAttribute = $this->getAttributeFromTag($oldText, $attribute);
+        $newAttribute = $this->getAttributeFromTag($newText, $attribute);
+
+        if ($oldAttribute !== $newAttribute) {
+            $diffClass = sprintf('diffmod diff%s diff%s', $element, $attribute);
+
+            return sprintf(
+                '%s%s',
+                $this->wrapText($oldText, 'del', $diffClass),
+                $this->wrapText($newText, 'ins', $diffClass)
+            );
+        }
+
+        return $this->diffElements($oldText, $newText);
+    }
+
+    /**
+     * @param Operation $operation
+     */
+    protected function processEqualOperation($operation)
+    {
+        $result = array();
+        foreach ($this->newWords as $pos => $s) {
+            if ($pos >= $operation->startInNew && $pos < $operation->endInNew) {
+                if ($this->config->isIsolatedDiffTagPlaceholder($s) && isset($this->newIsolatedDiffTags[$pos])) {
+                    $result[] = $this->diffIsolatedPlaceholder($operation, $pos, $s);
+                } else {
+                    $result[] = $s;
+                }
+            }
+        }
+        $this->content .= implode('', $result);
+    }
+
+    /**
+     * @param string $text
+     * @param string $attribute
+     *
+     * @return null|string
+     */
+    protected function getAttributeFromTag($text, $attribute)
+    {
+        $matches = array();
+        if (preg_match(sprintf('/<[^>]*\b%s\s*=\s*([\'"])(.*)\1[^>]*>/i', $attribute), $text, $matches)) {
+            return htmlspecialchars_decode($matches[2]);
+        }
+
+        return;
+    }
+
+    /**
+     * @param string $text
+     *
+     * @return bool
+     */
+    protected function isListPlaceholder($text)
+    {
+        return $this->isPlaceholderType($text, array('ol', 'dl', 'ul'));
+    }
+
+    /**
+     * @param string $text
+     *
+     * @return bool
+     */
+    public function isLinkPlaceholder($text)
+    {
+        return $this->isPlaceholderType($text, 'a');
+    }
+
+    /**
+     * @param string $text
+     *
+     * @return bool
+     */
+    public function isImagePlaceholder($text)
+    {
+        return $this->isPlaceholderType($text, 'img');
+    }
+
+    /**
+     * @param string       $text
+     * @param array|string $types
+     * @param bool         $strict
+     *
+     * @return bool
+     */
+    protected function isPlaceholderType($text, $types, $strict = true)
+    {
+        if (!is_array($types)) {
+            $types = array($types);
+        }
+
+        $criteria = array();
+        foreach ($types as $type) {
+            if ($this->config->isIsolatedDiffTag($type)) {
+                $criteria[] = $this->config->getIsolatedDiffTagPlaceholder($type);
+            } else {
+                $criteria[] = $type;
+            }
+        }
+
+        return in_array($text, $criteria, $strict);
+    }
+
+    /**
+     * @param string $text
+     *
+     * @return bool
+     */
+    protected function isTablePlaceholder($text)
+    {
+        return $this->isPlaceholderType($text, 'table');
+    }
+
+    /**
+     * @param Operation $operation
+     * @param int       $posInNew
+     *
+     * @return array
+     */
+    protected function findIsolatedDiffTagsInOld($operation, $posInNew)
+    {
+        $offset = $posInNew - $operation->startInNew;
+
+        return $this->oldIsolatedDiffTags[$operation->startInOld + $offset];
+    }
+
+    /**
+     * @param string $tag
+     * @param string $cssClass
+     * @param array  $words
+     */
+    protected function insertTag($tag, $cssClass, &$words)
+    {
+        while (true) {
+            if (count($words) == 0) {
+                break;
+            }
+
+            $nonTags = $this->extractConsecutiveWords($words, 'noTag');
+
+            $specialCaseTagInjection = '';
+            $specialCaseTagInjectionIsBefore = false;
+
+            if (count($nonTags) != 0) {
+                $text = $this->wrapText(implode('', $nonTags), $tag, $cssClass);
+                $this->content .= $text;
+            } else {
+                $firstOrDefault = false;
+                foreach ($this->config->getSpecialCaseOpeningTags() as $x) {
+                    if (preg_match($x, $words[ 0 ])) {
+                        $firstOrDefault = $x;
+                        break;
+                    }
+                }
+                if ($firstOrDefault) {
+                    $specialCaseTagInjection = '<ins class="mod">';
+                    if ($tag == 'del') {
+                        unset($words[ 0 ]);
+                    }
+                } elseif (array_search($words[ 0 ], $this->config->getSpecialCaseClosingTags()) !== false) {
+                    $specialCaseTagInjection = '</ins>';
+                    $specialCaseTagInjectionIsBefore = true;
+                    if ($tag == 'del') {
+                        unset($words[ 0 ]);
+                    }
+                }
+            }
+            if (count($words) == 0 && count($specialCaseTagInjection) == 0) {
+                break;
+            }
+            if ($specialCaseTagInjectionIsBefore) {
+                $this->content .= $specialCaseTagInjection.implode('', $this->extractConsecutiveWords($words, 'tag'));
+            } else {
+                $workTag = $this->extractConsecutiveWords($words, 'tag');
+                if (isset($workTag[ 0 ]) && $this->isOpeningTag($workTag[ 0 ]) && !$this->isClosingTag($workTag[ 0 ])) {
+                    if (strpos($workTag[ 0 ], 'class=')) {
+                        $workTag[ 0 ] = str_replace('class="', 'class="diffmod ', $workTag[ 0 ]);
+                        $workTag[ 0 ] = str_replace("class='", 'class="diffmod ', $workTag[ 0 ]);
+                    } else {
+                        $workTag[ 0 ] = str_replace('>', ' class="diffmod">', $workTag[ 0 ]);
+                    }
+                }
+
+                $appendContent = implode('', $workTag).$specialCaseTagInjection;
+                if (isset($workTag[0]) && false !== stripos($workTag[0], '<img')) {
+                    $appendContent = $this->wrapText($appendContent, $tag, $cssClass);
+                }
+                $this->content .= $appendContent;
+            }
+        }
+    }
+
+    /**
+     * @param string $word
+     * @param string $condition
+     *
+     * @return bool
+     */
+    protected function checkCondition($word, $condition)
+    {
+        return $condition == 'tag' ? $this->isTag($word) : !$this->isTag($word);
+    }
+
+    /**
+     * @param string $text
+     * @param string $tagName
+     * @param string $cssClass
+     *
+     * @return string
+     */
+    protected function wrapText($text, $tagName, $cssClass)
+    {
+        return sprintf('<%1$s class="%2$s">%3$s</%1$s>', $tagName, $cssClass, $text);
+    }
+
+    /**
+     * @param array  $words
+     * @param string $condition
+     *
+     * @return array
+     */
+    protected function extractConsecutiveWords(&$words, $condition)
+    {
+        $indexOfFirstTag = null;
+        $words = array_values($words);
+        foreach ($words as $i => $word) {
+            if (!$this->checkCondition($word, $condition)) {
+                $indexOfFirstTag = $i;
+                break;
+            }
+        }
+        if ($indexOfFirstTag !== null) {
+            $items = array();
+            foreach ($words as $pos => $s) {
+                if ($pos >= 0 && $pos < $indexOfFirstTag) {
+                    $items[] = $s;
+                }
+            }
+            if ($indexOfFirstTag > 0) {
+                array_splice($words, 0, $indexOfFirstTag);
+            }
+
+            return $items;
+        } else {
+            $items = array();
+            foreach ($words as $pos => $s) {
+                if ($pos >= 0 && $pos <= count($words)) {
+                    $items[] = $s;
+                }
+            }
+            array_splice($words, 0, count($words));
+
+            return $items;
+        }
+    }
+
+    /**
+     * @param string $item
+     *
+     * @return bool
+     */
+    protected function isTag($item)
+    {
+        return $this->isOpeningTag($item) || $this->isClosingTag($item);
+    }
+
+    /**
+     * @param string $item
+     *
+     * @return bool
+     */
+    protected function isOpeningTag($item)
+    {
+        return preg_match('#<[^>]+>\\s*#iU', $item);
+    }
+
+    /**
+     * @param string $item
+     *
+     * @return bool
+     */
+    protected function isClosingTag($item)
+    {
+        return preg_match('#</[^>]+>\\s*#iU', $item);
+    }
+
+    /**
+     * @return Operation[]
+     */
+    protected function operations()
+    {
+        $positionInOld = 0;
+        $positionInNew = 0;
+        $operations = array();
+
+        $matches   = $this->matchingBlocks();
+        $matches[] = new Match(count($this->oldWords), count($this->newWords), 0);
+
+        foreach ($matches as $i => $match) {
+            $matchStartsAtCurrentPositionInOld = ($positionInOld === $match->startInOld);
+            $matchStartsAtCurrentPositionInNew = ($positionInNew === $match->startInNew);
+
+            if ($matchStartsAtCurrentPositionInOld === false && $matchStartsAtCurrentPositionInNew === false) {
+                $action = 'replace';
+            } elseif ($matchStartsAtCurrentPositionInOld === true && $matchStartsAtCurrentPositionInNew === false) {
+                $action = 'insert';
+            } elseif ($matchStartsAtCurrentPositionInOld === false && $matchStartsAtCurrentPositionInNew === true) {
+                $action = 'delete';
+            } else { // This occurs if the first few words are the same in both versions
+                $action = 'none';
+            }
+
+            if ($action !== 'none') {
+                $operations[] = new Operation($action, $positionInOld, $match->startInOld, $positionInNew, $match->startInNew);
+            }
+
+            if (count($match) !== 0) {
+                $operations[] = new Operation('equal', $match->startInOld, $match->endInOld(), $match->startInNew, $match->endInNew());
+            }
+
+            $positionInOld = $match->endInOld();
+            $positionInNew = $match->endInNew();
+        }
+
+        return $operations;
+    }
+
+    /**
+     * @return Match[]
+     */
+    protected function matchingBlocks()
+    {
+        $matchingBlocks = array();
+        $this->findMatchingBlocks(0, count($this->oldWords), 0, count($this->newWords), $matchingBlocks);
+
+        return $matchingBlocks;
+    }
+
+    /**
+     * @param int   $startInOld
+     * @param int   $endInOld
+     * @param int   $startInNew
+     * @param int   $endInNew
+     * @param array $matchingBlocks
+     */
+    protected function findMatchingBlocks($startInOld, $endInOld, $startInNew, $endInNew, &$matchingBlocks)
+    {
+        $match = $this->findMatch($startInOld, $endInOld, $startInNew, $endInNew);
+
+        if ($match !== null) {
+            if ($startInOld < $match->startInOld && $startInNew < $match->startInNew) {
+                $this->findMatchingBlocks($startInOld, $match->startInOld, $startInNew, $match->startInNew, $matchingBlocks);
+            }
+
+            $matchingBlocks[] = $match;
+
+            if ($match->endInOld() < $endInOld && $match->endInNew() < $endInNew) {
+                $this->findMatchingBlocks($match->endInOld(), $endInOld, $match->endInNew(), $endInNew, $matchingBlocks);
+            }
+        }
+    }
+
+    /**
+     * @param string $word
+     *
+     * @return string
+     */
+    protected function stripTagAttributes($word)
+    {
+        $space = strpos($word, ' ', 1);
+
+        if ($space) {
+            return '<' . substr($word, 1, $space) . '>';
+        }
+
+        return trim($word, '<>');
+    }
+
+    /**
+     * @param int $startInOld
+     * @param int $endInOld
+     * @param int $startInNew
+     * @param int $endInNew
+     *
+     * @return Match|null
+     */
+    protected function findMatch($startInOld, $endInOld, $startInNew, $endInNew)
+    {
+        $bestMatchInOld = $startInOld;
+        $bestMatchInNew = $startInNew;
+        $bestMatchSize = 0;
+        $matchLengthAt = array();
+
+        for ($indexInOld = $startInOld; $indexInOld < $endInOld; ++$indexInOld) {
+            $newMatchLengthAt = array();
+            $index = $this->oldWords[ $indexInOld ];
+            if ($this->isTag($index)) {
+                $index = $this->stripTagAttributes($index);
+            }
+            if (!isset($this->wordIndices[ $index ])) {
+                $matchLengthAt = $newMatchLengthAt;
+                continue;
+            }
+            foreach ($this->wordIndices[ $index ] as $indexInNew) {
+                if ($indexInNew < $startInNew) {
+                    continue;
+                }
+                if ($indexInNew >= $endInNew) {
+                    break;
+                }
+
+                $newMatchLength = (isset($matchLengthAt[ $indexInNew - 1 ]) ? $matchLengthAt[ $indexInNew - 1 ] : 0) + 1;
+                $newMatchLengthAt[ $indexInNew ] = $newMatchLength;
+
+                if ($newMatchLength > $bestMatchSize ||
+                    (
+                        $this->isGroupDiffs() &&
+                        $bestMatchSize > 0 &&
+                        $this->isOnlyWhitespace($this->array_slice_cached($this->oldWords, $bestMatchInOld, $bestMatchSize))
+                    )
+                ) {
+                    $bestMatchInOld = $indexInOld - $newMatchLength + 1;
+                    $bestMatchInNew = $indexInNew - $newMatchLength + 1;
+                    $bestMatchSize = $newMatchLength;
+                }
+            }
+            $matchLengthAt = $newMatchLengthAt;
+        }
+
+        // Skip match if none found or match consists only of whitespace
+        if ($bestMatchSize != 0 &&
+            (
+                !$this->isGroupDiffs() ||
+                !$this->isOnlyWhitespace($this->array_slice_cached($this->oldWords, $bestMatchInOld, $bestMatchSize))
+            )
+        ) {
+            return new Match($bestMatchInOld, $bestMatchInNew, $bestMatchSize);
+        }
+
+        return null;
+    }
+
+    /**
+     * @param string $str
+     *
+     * @return bool
+     */
+    protected function isOnlyWhitespace($str)
+    {
+        //  Slightly faster then using preg_match
+        return $str !== '' && (strlen(trim($str)) === 0);
+    }
+
+    /**
+     * Special array_slice function that caches its last request.
+     *
+     * The diff algorithm seems to request the same information many times in a row.
+     * by returning the previous answer the algorithm preforms way faster.
+     *
+     * The result is a string instead of an array, this way we safe on the amount of
+     * memory intensive implode() calls.
+     *
+     * @param array         &$array
+     * @param integer       $offset
+     * @param integer|null  $length
+     *
+     * @return string
+     */
+    protected function array_slice_cached(&$array, $offset, $length = null)
+    {
+        static $lastOffset = null;
+        static $lastLength = null;
+        static $cache      = null;
+
+        // PHP has no support for by-reference comparing.
+        // to prevent false positive hits, reset the cache when the oldWords or newWords is changed.
+        if ($this->resetCache === true) {
+            $cache = null;
+
+            $this->resetCache = false;
+        }
+
+        if (
+            $cache !== null &&
+            $lastLength === $length &&
+            $lastOffset === $offset
+        ) { // Hit
+            return $cache;
+        } // Miss
+
+        $lastOffset = $offset;
+        $lastLength = $length;
+
+        $cache = implode('', array_slice($array, $offset, $length));
+
+        return $cache;
+    }
+}