2779d7777ae4bee125bc63721c182da43d2c1f57
[yaffs-website] / vendor / caxy / php-htmldiff / lib / Caxy / HtmlDiff / Table / TableDiff.php
1 <?php
2
3 namespace Caxy\HtmlDiff\Table;
4
5 use Caxy\HtmlDiff\AbstractDiff;
6 use Caxy\HtmlDiff\HtmlDiff;
7 use Caxy\HtmlDiff\HtmlDiffConfig;
8 use Caxy\HtmlDiff\Operation;
9
10 /**
11  * Class TableDiff.
12  */
13 class TableDiff extends AbstractDiff
14 {
15     /**
16      * @var null|Table
17      */
18     protected $oldTable = null;
19
20     /**
21      * @var null|Table
22      */
23     protected $newTable = null;
24
25     /**
26      * @var null|\DOMElement
27      */
28     protected $diffTable = null;
29
30     /**
31      * @var null|\DOMDocument
32      */
33     protected $diffDom = null;
34
35     /**
36      * @var int
37      */
38     protected $newRowOffsets = 0;
39
40     /**
41      * @var int
42      */
43     protected $oldRowOffsets = 0;
44
45     /**
46      * @var array
47      */
48     protected $cellValues = array();
49
50     /**
51      * @param string              $oldText
52      * @param string              $newText
53      * @param HtmlDiffConfig|null $config
54      *
55      * @return self
56      */
57     public static function create($oldText, $newText, HtmlDiffConfig $config = null)
58     {
59         $diff = new self($oldText, $newText);
60
61         if (null !== $config) {
62             $diff->setConfig($config);
63         }
64
65         return $diff;
66     }
67
68     /**
69      * TableDiff constructor.
70      *
71      * @param string     $oldText
72      * @param string     $newText
73      * @param string     $encoding
74      * @param array|null $specialCaseTags
75      * @param bool|null  $groupDiffs
76      */
77     public function __construct(
78         $oldText,
79         $newText,
80         $encoding = 'UTF-8',
81         $specialCaseTags = null,
82         $groupDiffs = null
83     ) {
84         parent::__construct($oldText, $newText, $encoding, $specialCaseTags, $groupDiffs);
85     }
86
87     /**
88      * @return string
89      */
90     public function build()
91     {
92         $this->prepare();
93
94         if ($this->hasDiffCache() && $this->getDiffCache()->contains($this->oldText, $this->newText)) {
95             $this->content = $this->getDiffCache()->fetch($this->oldText, $this->newText);
96
97             return $this->content;
98         }
99
100         $this->buildTableDoms();
101
102         $this->diffDom = new \DOMDocument();
103
104         $this->indexCellValues($this->newTable);
105
106         $this->diffTableContent();
107
108         if ($this->hasDiffCache()) {
109             $this->getDiffCache()->save($this->oldText, $this->newText, $this->content);
110         }
111
112         return $this->content;
113     }
114
115     protected function diffTableContent()
116     {
117         $this->diffDom = new \DOMDocument();
118         $this->diffTable = $this->newTable->cloneNode($this->diffDom);
119         $this->diffDom->appendChild($this->diffTable);
120
121         $oldRows = $this->oldTable->getRows();
122         $newRows = $this->newTable->getRows();
123
124         $oldMatchData = array();
125         $newMatchData = array();
126
127         /* @var $oldRow TableRow */
128         foreach ($oldRows as $oldIndex => $oldRow) {
129             $oldMatchData[$oldIndex] = array();
130
131             // Get match percentages
132             /* @var $newRow TableRow */
133             foreach ($newRows as $newIndex => $newRow) {
134                 if (!array_key_exists($newIndex, $newMatchData)) {
135                     $newMatchData[$newIndex] = array();
136                 }
137
138                 // similar_text
139                 $percentage = $this->getMatchPercentage($oldRow, $newRow, $oldIndex, $newIndex);
140
141                 $oldMatchData[$oldIndex][$newIndex] = $percentage;
142                 $newMatchData[$newIndex][$oldIndex] = $percentage;
143             }
144         }
145
146         $matches = $this->getRowMatches($oldMatchData, $newMatchData);
147         $this->diffTableRowsWithMatches($oldRows, $newRows, $matches);
148
149         $this->content = $this->htmlFromNode($this->diffTable);
150     }
151
152     /**
153      * @param TableRow[] $oldRows
154      * @param TableRow[] $newRows
155      * @param RowMatch[] $matches
156      */
157     protected function diffTableRowsWithMatches($oldRows, $newRows, $matches)
158     {
159         $operations = array();
160
161         $indexInOld = 0;
162         $indexInNew = 0;
163
164         $oldRowCount = count($oldRows);
165         $newRowCount = count($newRows);
166
167         $matches[] = new RowMatch($newRowCount, $oldRowCount, $newRowCount, $oldRowCount);
168
169         // build operations
170         foreach ($matches as $match) {
171             $matchAtIndexInOld = $indexInOld === $match->getStartInOld();
172             $matchAtIndexInNew = $indexInNew === $match->getStartInNew();
173
174             $action = 'equal';
175
176             if (!$matchAtIndexInOld && !$matchAtIndexInNew) {
177                 $action = 'replace';
178             } elseif ($matchAtIndexInOld && !$matchAtIndexInNew) {
179                 $action = 'insert';
180             } elseif (!$matchAtIndexInOld && $matchAtIndexInNew) {
181                 $action = 'delete';
182             }
183
184             if ($action !== 'equal') {
185                 $operations[] = new Operation(
186                     $action,
187                     $indexInOld,
188                     $match->getStartInOld(),
189                     $indexInNew,
190                     $match->getStartInNew()
191                 );
192             }
193
194             $operations[] = new Operation(
195                 'equal',
196                 $match->getStartInOld(),
197                 $match->getEndInOld(),
198                 $match->getStartInNew(),
199                 $match->getEndInNew()
200             );
201
202             $indexInOld = $match->getEndInOld();
203             $indexInNew = $match->getEndInNew();
204         }
205
206         $appliedRowSpans = array();
207
208         // process operations
209         foreach ($operations as $operation) {
210             switch ($operation->action) {
211                 case 'equal':
212                     $this->processEqualOperation($operation, $oldRows, $newRows, $appliedRowSpans);
213                     break;
214
215                 case 'delete':
216                     $this->processDeleteOperation($operation, $oldRows, $appliedRowSpans);
217                     break;
218
219                 case 'insert':
220                     $this->processInsertOperation($operation, $newRows, $appliedRowSpans);
221                     break;
222
223                 case 'replace':
224                     $this->processReplaceOperation($operation, $oldRows, $newRows, $appliedRowSpans);
225                     break;
226             }
227         }
228     }
229
230     /**
231      * @param Operation $operation
232      * @param array     $newRows
233      * @param array     $appliedRowSpans
234      * @param bool      $forceExpansion
235      */
236     protected function processInsertOperation(
237         Operation $operation,
238         $newRows,
239         &$appliedRowSpans,
240         $forceExpansion = false
241     ) {
242         $targetRows = array_slice($newRows, $operation->startInNew, $operation->endInNew - $operation->startInNew);
243         foreach ($targetRows as $row) {
244             $this->diffAndAppendRows(null, $row, $appliedRowSpans, $forceExpansion);
245         }
246     }
247
248     /**
249      * @param Operation $operation
250      * @param array     $oldRows
251      * @param array     $appliedRowSpans
252      * @param bool      $forceExpansion
253      */
254     protected function processDeleteOperation(
255         Operation $operation,
256         $oldRows,
257         &$appliedRowSpans,
258         $forceExpansion = false
259     ) {
260         $targetRows = array_slice($oldRows, $operation->startInOld, $operation->endInOld - $operation->startInOld);
261         foreach ($targetRows as $row) {
262             $this->diffAndAppendRows($row, null, $appliedRowSpans, $forceExpansion);
263         }
264     }
265
266     /**
267      * @param Operation $operation
268      * @param array     $oldRows
269      * @param array     $newRows
270      * @param array     $appliedRowSpans
271      */
272     protected function processEqualOperation(Operation $operation, $oldRows, $newRows, &$appliedRowSpans)
273     {
274         $targetOldRows = array_values(
275             array_slice($oldRows, $operation->startInOld, $operation->endInOld - $operation->startInOld)
276         );
277         $targetNewRows = array_values(
278             array_slice($newRows, $operation->startInNew, $operation->endInNew - $operation->startInNew)
279         );
280
281         foreach ($targetNewRows as $index => $newRow) {
282             if (!isset($targetOldRows[$index])) {
283                 continue;
284             }
285
286             $this->diffAndAppendRows($targetOldRows[$index], $newRow, $appliedRowSpans);
287         }
288     }
289
290     /**
291      * @param Operation $operation
292      * @param array     $oldRows
293      * @param array     $newRows
294      * @param array     $appliedRowSpans
295      */
296     protected function processReplaceOperation(Operation $operation, $oldRows, $newRows, &$appliedRowSpans)
297     {
298         $this->processDeleteOperation($operation, $oldRows, $appliedRowSpans, true);
299         $this->processInsertOperation($operation, $newRows, $appliedRowSpans, true);
300     }
301
302     /**
303      * @param array $oldMatchData
304      * @param array $newMatchData
305      *
306      * @return array
307      */
308     protected function getRowMatches($oldMatchData, $newMatchData)
309     {
310         $matches = array();
311
312         $startInOld = 0;
313         $startInNew = 0;
314         $endInOld = count($oldMatchData);
315         $endInNew = count($newMatchData);
316
317         $this->findRowMatches($newMatchData, $startInOld, $endInOld, $startInNew, $endInNew, $matches);
318
319         return $matches;
320     }
321
322     /**
323      * @param array $newMatchData
324      * @param int   $startInOld
325      * @param int   $endInOld
326      * @param int   $startInNew
327      * @param int   $endInNew
328      * @param array $matches
329      */
330     protected function findRowMatches($newMatchData, $startInOld, $endInOld, $startInNew, $endInNew, &$matches)
331     {
332         $match = $this->findRowMatch($newMatchData, $startInOld, $endInOld, $startInNew, $endInNew);
333         if ($match !== null) {
334             if ($startInOld < $match->getStartInOld() &&
335                 $startInNew < $match->getStartInNew()
336             ) {
337                 $this->findRowMatches(
338                     $newMatchData,
339                     $startInOld,
340                     $match->getStartInOld(),
341                     $startInNew,
342                     $match->getStartInNew(),
343                     $matches
344                 );
345             }
346
347             $matches[] = $match;
348
349             if ($match->getEndInOld() < $endInOld &&
350                 $match->getEndInNew() < $endInNew
351             ) {
352                 $this->findRowMatches(
353                     $newMatchData,
354                     $match->getEndInOld(),
355                     $endInOld,
356                     $match->getEndInNew(),
357                     $endInNew,
358                     $matches
359                 );
360             }
361         }
362     }
363
364     /**
365      * @param array $newMatchData
366      * @param int   $startInOld
367      * @param int   $endInOld
368      * @param int   $startInNew
369      * @param int   $endInNew
370      *
371      * @return RowMatch|null
372      */
373     protected function findRowMatch($newMatchData, $startInOld, $endInOld, $startInNew, $endInNew)
374     {
375         $bestMatch = null;
376         $bestPercentage = 0;
377
378         foreach ($newMatchData as $newIndex => $oldMatches) {
379             if ($newIndex < $startInNew) {
380                 continue;
381             }
382
383             if ($newIndex >= $endInNew) {
384                 break;
385             }
386             foreach ($oldMatches as $oldIndex => $percentage) {
387                 if ($oldIndex < $startInOld) {
388                     continue;
389                 }
390
391                 if ($oldIndex >= $endInOld) {
392                     break;
393                 }
394
395                 if ($percentage > $bestPercentage) {
396                     $bestPercentage = $percentage;
397                     $bestMatch = array(
398                         'oldIndex' => $oldIndex,
399                         'newIndex' => $newIndex,
400                         'percentage' => $percentage,
401                     );
402                 }
403             }
404         }
405
406         if ($bestMatch !== null) {
407             return new RowMatch(
408                 $bestMatch['newIndex'],
409                 $bestMatch['oldIndex'],
410                 $bestMatch['newIndex'] + 1,
411                 $bestMatch['oldIndex'] + 1,
412                 $bestMatch['percentage']
413             );
414         }
415
416         return;
417     }
418
419     /**
420      * @param TableRow|null $oldRow
421      * @param TableRow|null $newRow
422      * @param array         $appliedRowSpans
423      * @param bool          $forceExpansion
424      *
425      * @return array
426      */
427     protected function diffRows($oldRow, $newRow, array &$appliedRowSpans, $forceExpansion = false)
428     {
429         // create tr dom element
430         $rowToClone = $newRow ?: $oldRow;
431         /* @var $diffRow \DOMElement */
432         $diffRow = $this->diffDom->importNode($rowToClone->getDomNode()->cloneNode(false), false);
433
434         $oldCells = $oldRow ? $oldRow->getCells() : array();
435         $newCells = $newRow ? $newRow->getCells() : array();
436
437         $position = new DiffRowPosition();
438
439         $extraRow = null;
440
441         /* @var $expandCells \DOMElement[] */
442         $expandCells = array();
443         /* @var $cellsWithMultipleRows \DOMElement[] */
444         $cellsWithMultipleRows = array();
445
446         $newCellCount = count($newCells);
447         while ($position->getIndexInNew() < $newCellCount) {
448             if (!$position->areColumnsEqual()) {
449                 $type = $position->getLesserColumnType();
450                 if ($type === 'new') {
451                     $row = $newRow;
452                     $targetRow = $extraRow;
453                 } else {
454                     $row = $oldRow;
455                     $targetRow = $diffRow;
456                 }
457                 if ($row && $targetRow && (!$type === 'old' || isset($oldCells[$position->getIndexInOld()]))) {
458                     $this->syncVirtualColumns($row, $position, $cellsWithMultipleRows, $targetRow, $type, true);
459
460                     continue;
461                 }
462             }
463
464             /* @var $newCell TableCell */
465             $newCell = $newCells[$position->getIndexInNew()];
466             /* @var $oldCell TableCell */
467             $oldCell = isset($oldCells[$position->getIndexInOld()]) ? $oldCells[$position->getIndexInOld()] : null;
468
469             if ($oldCell && $newCell->getColspan() != $oldCell->getColspan()) {
470                 if (null === $extraRow) {
471                     /* @var $extraRow \DOMElement */
472                     $extraRow = $this->diffDom->importNode($rowToClone->getDomNode()->cloneNode(false), false);
473                 }
474
475                 if ($oldCell->getColspan() > $newCell->getColspan()) {
476                     $this->diffCellsAndIncrementCounters(
477                         $oldCell,
478                         null,
479                         $cellsWithMultipleRows,
480                         $diffRow,
481                         $position,
482                         true
483                     );
484                     $this->syncVirtualColumns($newRow, $position, $cellsWithMultipleRows, $extraRow, 'new', true);
485                 } else {
486                     $this->diffCellsAndIncrementCounters(
487                         null,
488                         $newCell,
489                         $cellsWithMultipleRows,
490                         $extraRow,
491                         $position,
492                         true
493                     );
494                     $this->syncVirtualColumns($oldRow, $position, $cellsWithMultipleRows, $diffRow, 'old', true);
495                 }
496             } else {
497                 $diffCell = $this->diffCellsAndIncrementCounters(
498                     $oldCell,
499                     $newCell,
500                     $cellsWithMultipleRows,
501                     $diffRow,
502                     $position
503                 );
504                 $expandCells[] = $diffCell;
505             }
506         }
507
508         $oldCellCount = count($oldCells);
509         while ($position->getIndexInOld() < $oldCellCount) {
510             $diffCell = $this->diffCellsAndIncrementCounters(
511                 $oldCells[$position->getIndexInOld()],
512                 null,
513                 $cellsWithMultipleRows,
514                 $diffRow,
515                 $position
516             );
517             $expandCells[] = $diffCell;
518         }
519
520         if ($extraRow) {
521             foreach ($expandCells as $expandCell) {
522                 $rowspan = $expandCell->getAttribute('rowspan') ?: 1;
523                 $expandCell->setAttribute('rowspan', 1 + $rowspan);
524             }
525         }
526
527         if ($extraRow || $forceExpansion) {
528             foreach ($appliedRowSpans as $rowSpanCells) {
529                 /* @var $rowSpanCells \DOMElement[] */
530                 foreach ($rowSpanCells as $extendCell) {
531                     $rowspan = $extendCell->getAttribute('rowspan') ?: 1;
532                     $extendCell->setAttribute('rowspan', 1 + $rowspan);
533                 }
534             }
535         }
536
537         if (!$forceExpansion) {
538             array_shift($appliedRowSpans);
539             $appliedRowSpans = array_values($appliedRowSpans);
540         }
541         $appliedRowSpans = array_merge($appliedRowSpans, array_values($cellsWithMultipleRows));
542
543         return array($diffRow, $extraRow);
544     }
545
546     /**
547      * @param TableCell|null $oldCell
548      * @param TableCell|null $newCell
549      *
550      * @return \DOMElement
551      */
552     protected function getNewCellNode(TableCell $oldCell = null, TableCell $newCell = null)
553     {
554         // If only one cell exists, use it
555         if (!$oldCell || !$newCell) {
556             $clone = $newCell
557                 ? $newCell->getDomNode()->cloneNode(false)
558                 : $oldCell->getDomNode()->cloneNode(false);
559         } else {
560             $oldNode = $oldCell->getDomNode();
561             $newNode = $newCell->getDomNode();
562
563             /* @var $clone \DOMElement */
564             $clone = $newNode->cloneNode(false);
565
566             $oldRowspan = $oldNode->getAttribute('rowspan') ?: 1;
567             $oldColspan = $oldNode->getAttribute('colspan') ?: 1;
568             $newRowspan = $newNode->getAttribute('rowspan') ?: 1;
569             $newColspan = $newNode->getAttribute('colspan') ?: 1;
570
571             $clone->setAttribute('rowspan', max($oldRowspan, $newRowspan));
572             $clone->setAttribute('colspan', max($oldColspan, $newColspan));
573         }
574
575         return $this->diffDom->importNode($clone);
576     }
577
578     /**
579      * @param TableCell|null $oldCell
580      * @param TableCell|null $newCell
581      * @param bool           $usingExtraRow
582      *
583      * @return \DOMElement
584      */
585     protected function diffCells($oldCell, $newCell, $usingExtraRow = false)
586     {
587         $diffCell = $this->getNewCellNode($oldCell, $newCell);
588
589         $oldContent = $oldCell ? $this->getInnerHtml($oldCell->getDomNode()) : '';
590         $newContent = $newCell ? $this->getInnerHtml($newCell->getDomNode()) : '';
591
592         $htmlDiff = HtmlDiff::create(
593             mb_convert_encoding($oldContent, 'UTF-8', 'HTML-ENTITIES'),
594             mb_convert_encoding($newContent, 'UTF-8', 'HTML-ENTITIES'),
595             $this->config
596         );
597         $diff = $htmlDiff->build();
598
599         $this->setInnerHtml($diffCell, $diff);
600
601         if (null === $newCell) {
602             $diffCell->setAttribute('class', trim($diffCell->getAttribute('class').' del'));
603         }
604
605         if (null === $oldCell) {
606             $diffCell->setAttribute('class', trim($diffCell->getAttribute('class').' ins'));
607         }
608
609         if ($usingExtraRow) {
610             $diffCell->setAttribute('class', trim($diffCell->getAttribute('class').' extra-row'));
611         }
612
613         return $diffCell;
614     }
615
616     protected function buildTableDoms()
617     {
618         $this->oldTable = $this->parseTableStructure($this->oldText);
619         $this->newTable = $this->parseTableStructure($this->newText);
620     }
621
622     /**
623      * @param string $text
624      *
625      * @return \DOMDocument
626      */
627     protected function createDocumentWithHtml($text)
628     {
629         $dom = new \DOMDocument();
630         $dom->loadHTML(mb_convert_encoding(
631             $this->purifier->purify(mb_convert_encoding($text, $this->config->getEncoding(), mb_detect_encoding($text))),
632             'HTML-ENTITIES',
633             $this->config->getEncoding()
634         ));
635
636         return $dom;
637     }
638
639     /**
640      * @param string $text
641      *
642      * @return Table
643      */
644     protected function parseTableStructure($text)
645     {
646         $dom = $this->createDocumentWithHtml($text);
647
648         $tableNode = $dom->getElementsByTagName('table')->item(0);
649
650         $table = new Table($tableNode);
651
652         $this->parseTable($table);
653
654         return $table;
655     }
656
657     /**
658      * @param Table         $table
659      * @param \DOMNode|null $node
660      */
661     protected function parseTable(Table $table, \DOMNode $node = null)
662     {
663         if ($node === null) {
664             $node = $table->getDomNode();
665         }
666
667         if (!$node->childNodes) {
668             return;
669         }
670
671         foreach ($node->childNodes as $child) {
672             if ($child->nodeName === 'tr') {
673                 $row = new TableRow($child);
674                 $table->addRow($row);
675
676                 $this->parseTableRow($row);
677             } else {
678                 $this->parseTable($table, $child);
679             }
680         }
681     }
682
683     /**
684      * @param TableRow $row
685      */
686     protected function parseTableRow(TableRow $row)
687     {
688         $node = $row->getDomNode();
689
690         foreach ($node->childNodes as $child) {
691             if (in_array($child->nodeName, array('td', 'th'))) {
692                 $cell = new TableCell($child);
693                 $row->addCell($cell);
694             }
695         }
696     }
697
698     /**
699      * @param \DOMNode $node
700      *
701      * @return string
702      */
703     protected function getInnerHtml($node)
704     {
705         $innerHtml = '';
706         $children = $node->childNodes;
707
708         foreach ($children as $child) {
709             $innerHtml .= $this->htmlFromNode($child);
710         }
711
712         return $innerHtml;
713     }
714
715     /**
716      * @param \DOMNode $node
717      *
718      * @return string
719      */
720     protected function htmlFromNode($node)
721     {
722         $domDocument = new \DOMDocument();
723         $newNode = $domDocument->importNode($node, true);
724         $domDocument->appendChild($newNode);
725
726         return $domDocument->saveHTML();
727     }
728
729     /**
730      * @param \DOMNode $node
731      * @param string   $html
732      */
733     protected function setInnerHtml($node, $html)
734     {
735         // DOMDocument::loadHTML does not allow empty strings.
736         if (strlen(trim($html)) === 0) {
737             $html = '<span class="empty"></span>';
738         }
739
740         $doc = $this->createDocumentWithHtml($html);
741         $fragment = $node->ownerDocument->createDocumentFragment();
742         $root = $doc->getElementsByTagName('body')->item(0);
743         foreach ($root->childNodes as $child) {
744             $fragment->appendChild($node->ownerDocument->importNode($child, true));
745         }
746
747         $node->appendChild($fragment);
748     }
749
750     /**
751      * @param Table $table
752      */
753     protected function indexCellValues(Table $table)
754     {
755         foreach ($table->getRows() as $rowIndex => $row) {
756             foreach ($row->getCells() as $cellIndex => $cell) {
757                 $value = trim($cell->getDomNode()->textContent);
758
759                 if (!isset($this->cellValues[$value])) {
760                     $this->cellValues[$value] = array();
761                 }
762
763                 $this->cellValues[$value][] = new TablePosition($rowIndex, $cellIndex);
764             }
765         }
766     }
767
768     /**
769      * @param TableRow        $tableRow
770      * @param DiffRowPosition $position
771      * @param array           $cellsWithMultipleRows
772      * @param \DOMNode        $diffRow
773      * @param string          $diffType
774      * @param bool            $usingExtraRow
775      */
776     protected function syncVirtualColumns(
777         $tableRow,
778         DiffRowPosition $position,
779         &$cellsWithMultipleRows,
780         $diffRow,
781         $diffType,
782         $usingExtraRow = false
783     ) {
784         $currentCell = $tableRow->getCell($position->getIndex($diffType));
785         while ($position->isColumnLessThanOther($diffType) && $currentCell) {
786             $diffCell = $diffType === 'new' ? $this->diffCells(null, $currentCell, $usingExtraRow) : $this->diffCells(
787                 $currentCell,
788                 null,
789                 $usingExtraRow
790             );
791             // Store cell in appliedRowSpans if spans multiple rows
792             if ($diffCell->getAttribute('rowspan') > 1) {
793                 $cellsWithMultipleRows[$diffCell->getAttribute('rowspan')][] = $diffCell;
794             }
795             $diffRow->appendChild($diffCell);
796             $position->incrementColumn($diffType, $currentCell->getColspan());
797             $currentCell = $tableRow->getCell($position->incrementIndex($diffType));
798         }
799     }
800
801     /**
802      * @param null|TableCell  $oldCell
803      * @param null|TableCell  $newCell
804      * @param array           $cellsWithMultipleRows
805      * @param \DOMElement     $diffRow
806      * @param DiffRowPosition $position
807      * @param bool            $usingExtraRow
808      *
809      * @return \DOMElement
810      */
811     protected function diffCellsAndIncrementCounters(
812         $oldCell,
813         $newCell,
814         &$cellsWithMultipleRows,
815         $diffRow,
816         DiffRowPosition $position,
817         $usingExtraRow = false
818     ) {
819         $diffCell = $this->diffCells($oldCell, $newCell, $usingExtraRow);
820         // Store cell in appliedRowSpans if spans multiple rows
821         if ($diffCell->getAttribute('rowspan') > 1) {
822             $cellsWithMultipleRows[$diffCell->getAttribute('rowspan')][] = $diffCell;
823         }
824         $diffRow->appendChild($diffCell);
825
826         if ($newCell !== null) {
827             $position->incrementIndexInNew();
828             $position->incrementColumnInNew($newCell->getColspan());
829         }
830
831         if ($oldCell !== null) {
832             $position->incrementIndexInOld();
833             $position->incrementColumnInOld($oldCell->getColspan());
834         }
835
836         return $diffCell;
837     }
838
839     /**
840      * @param TableRow|null $oldRow
841      * @param TableRow|null $newRow
842      * @param array         $appliedRowSpans
843      * @param bool          $forceExpansion
844      */
845     protected function diffAndAppendRows($oldRow, $newRow, &$appliedRowSpans, $forceExpansion = false)
846     {
847         list($rowDom, $extraRow) = $this->diffRows(
848             $oldRow,
849             $newRow,
850             $appliedRowSpans,
851             $forceExpansion
852         );
853
854         $this->diffTable->appendChild($rowDom);
855
856         if ($extraRow) {
857             $this->diffTable->appendChild($extraRow);
858         }
859     }
860
861     /**
862      * @param TableRow $oldRow
863      * @param TableRow $newRow
864      * @param int      $oldIndex
865      * @param int      $newIndex
866      *
867      * @return float|int
868      */
869     protected function getMatchPercentage(TableRow $oldRow, TableRow $newRow, $oldIndex, $newIndex)
870     {
871         $firstCellWeight = 1.5;
872         $indexDeltaWeight = 0.25 * (abs($oldIndex - $newIndex));
873         $thresholdCount = 0;
874         $minCells = min(count($newRow->getCells()), count($oldRow->getCells()));
875         $totalCount = ($minCells + $firstCellWeight + $indexDeltaWeight) * 100;
876         foreach ($newRow->getCells() as $newIndex => $newCell) {
877             $oldCell = $oldRow->getCell($newIndex);
878
879             if ($oldCell) {
880                 $percentage = null;
881                 similar_text($oldCell->getInnerHtml(), $newCell->getInnerHtml(), $percentage);
882
883                 if ($percentage > ($this->config->getMatchThreshold() * 0.50)) {
884                     $increment = $percentage;
885                     if ($newIndex === 0 && $percentage > 95) {
886                         $increment = $increment * $firstCellWeight;
887                     }
888                     $thresholdCount += $increment;
889                 }
890             }
891         }
892
893         return ($totalCount > 0) ? ($thresholdCount / $totalCount) : 0;
894     }
895 }