setConfig($config); } return $diff; } /** * {@inheritDoc} */ 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; } $matchStrategy = new ListItemMatchStrategy($this->config->getMatchThreshold()); $this->lcsService = new LcsService($matchStrategy); return $this->listByLines($this->oldText, $this->newText); } /** * @param string $old * @param string $new * * @return string */ protected function listByLines($old, $new) { /* @var $newDom \simple_html_dom */ $newDom = HtmlDomParser::str_get_html($new); /* @var $oldDom \simple_html_dom */ $oldDom = HtmlDomParser::str_get_html($old); $newListNode = $this->findListNode($newDom); $oldListNode = $this->findListNode($oldDom); $operations = $this->getListItemOperations($oldListNode, $newListNode); return $this->processOperations($operations, $oldListNode, $newListNode); } /** * @param \simple_html_dom|\simple_html_dom_node $dom * * @return \simple_html_dom_node[]|\simple_html_dom_node|null */ protected function findListNode($dom) { return $dom->find(implode(', ', static::$listTypes), 0); } /** * @param \simple_html_dom_node $oldListNode * @param \simple_html_dom_node $newListNode * * @return array|Operation[] */ protected function getListItemOperations($oldListNode, $newListNode) { // Prepare arrays of list item content to use in LCS algorithm $oldListText = $this->getListTextArray($oldListNode); $newListText = $this->getListTextArray($newListNode); $lcsMatches = $this->lcsService->longestCommonSubsequence($oldListText, $newListText); $oldLength = count($oldListText); $newLength = count($newListText); $operations = array(); $currentLineInOld = 0; $currentLineInNew = 0; $lcsMatches[$oldLength + 1] = $newLength + 1; foreach ($lcsMatches as $matchInOld => $matchInNew) { // No matching line in new list if ($matchInNew === 0) { continue; } $nextLineInOld = $currentLineInOld + 1; $nextLineInNew = $currentLineInNew + 1; if ($matchInNew > $nextLineInNew && $matchInOld > $nextLineInOld) { // Change $operations[] = new Operation( Operation::CHANGED, $nextLineInOld, $matchInOld - 1, $nextLineInNew, $matchInNew - 1 ); } elseif ($matchInNew > $nextLineInNew && $matchInOld === $nextLineInOld) { // Add items before this $operations[] = new Operation( Operation::ADDED, $currentLineInOld, $currentLineInOld, $nextLineInNew, $matchInNew - 1 ); } elseif ($matchInNew === $nextLineInNew && $matchInOld > $nextLineInOld) { // Delete items before this $operations[] = new Operation( Operation::DELETED, $nextLineInOld, $matchInOld - 1, $currentLineInNew, $currentLineInNew ); } $currentLineInNew = $matchInNew; $currentLineInOld = $matchInOld; } return $operations; } /** * @param \simple_html_dom_node $listNode * * @return array */ protected function getListTextArray($listNode) { $output = array(); foreach ($listNode->children() as $listItem) { $output[] = $this->getRelevantNodeText($listItem); } return $output; } /** * @param \simple_html_dom_node $node * * @return string */ protected function getRelevantNodeText($node) { if (!$node->hasChildNodes()) { return $node->innertext(); } $output = ''; foreach ($node->nodes as $child) { /* @var $child \simple_html_dom_node */ if (!$child->hasChildNodes()) { $output .= $child->outertext(); } elseif (in_array($child->nodeName(), static::$listContentTags, true)) { $output .= sprintf('<%1$s>%2$s', $child->nodeName(), $this->getRelevantNodeText($child)); } } return $output; } /** * @param \simple_html_dom_node $li * * @return string */ protected function deleteListItem($li) { $this->addClassToNode($li, self::CLASS_LIST_ITEM_DELETED); $li->innertext = sprintf('%s', $li->innertext); return $li->outertext; } /** * @param \simple_html_dom_node $li * @param bool $replacement * * @return string */ protected function addListItem($li, $replacement = false) { $this->addClassToNode($li, $replacement ? self::CLASS_LIST_ITEM_CHANGED : self::CLASS_LIST_ITEM_ADDED); $li->innertext = sprintf('%s', $li->innertext); return $li->outertext; } /** * @param Operation[]|array $operations * @param \simple_html_dom_node $oldListNode * @param \simple_html_dom_node $newListNode * * @return string */ protected function processOperations($operations, $oldListNode, $newListNode) { $output = ''; $indexInOld = 0; $indexInNew = 0; $lastOperation = null; foreach ($operations as $operation) { $replaced = false; while ($operation->startInOld > ($operation->action === Operation::ADDED ? $indexInOld : $indexInOld + 1)) { $li = $oldListNode->children($indexInOld); $matchingLi = null; if ($operation->startInNew > ($operation->action === Operation::DELETED ? $indexInNew : $indexInNew + 1) ) { $matchingLi = $newListNode->children($indexInNew); } if (null !== $matchingLi) { $htmlDiff = HtmlDiff::create($li->innertext, $matchingLi->innertext, $this->config); $li->innertext = $htmlDiff->build(); $indexInNew++; } $class = self::CLASS_LIST_ITEM_NONE; if ($lastOperation === Operation::DELETED && !$replaced) { $class = self::CLASS_LIST_ITEM_CHANGED; $replaced = true; } $li->setAttribute('class', trim($li->getAttribute('class').' '.$class)); $output .= $li->outertext; $indexInOld++; } switch ($operation->action) { case Operation::ADDED: for ($i = $operation->startInNew; $i <= $operation->endInNew; $i++) { $output .= $this->addListItem($newListNode->children($i - 1)); } $indexInNew = $operation->endInNew; break; case Operation::DELETED: for ($i = $operation->startInOld; $i <= $operation->endInOld; $i++) { $output .= $this->deleteListItem($oldListNode->children($i - 1)); } $indexInOld = $operation->endInOld; break; case Operation::CHANGED: $changeDelta = 0; for ($i = $operation->startInOld; $i <= $operation->endInOld; $i++) { $output .= $this->deleteListItem($oldListNode->children($i - 1)); $changeDelta--; } for ($i = $operation->startInNew; $i <= $operation->endInNew; $i++) { $output .= $this->addListItem($newListNode->children($i - 1), $changeDelta < 0); $changeDelta++; } $indexInOld = $operation->endInOld; $indexInNew = $operation->endInNew; break; } $lastOperation = $operation->action; } $oldCount = count($oldListNode->children()); $newCount = count($newListNode->children()); while ($indexInOld < $oldCount) { $li = $oldListNode->children($indexInOld); $matchingLi = null; if ($indexInNew < $newCount) { $matchingLi = $newListNode->children($indexInNew); } if (null !== $matchingLi) { $htmlDiff = HtmlDiff::create($li->innertext(), $matchingLi->innertext(), $this->config); $li->innertext = $htmlDiff->build(); $indexInNew++; } $class = self::CLASS_LIST_ITEM_NONE; if ($lastOperation === Operation::DELETED) { $class = self::CLASS_LIST_ITEM_CHANGED; } $li->setAttribute('class', trim($li->getAttribute('class').' '.$class)); $output .= $li->outertext; $indexInOld++; } $newListNode->innertext = $output; $newListNode->setAttribute('class', trim($newListNode->getAttribute('class').' diff-list')); return $newListNode->outertext; } /** * @param \simple_html_dom_node $node * @param string $class */ protected function addClassToNode($node, $class) { $node->setAttribute('class', trim(sprintf('%s %s', $node->getAttribute('class'), $class))); } }