Plugin to allow visitor contributions to WordPress posts, wiki style.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

796 lines
24KB

  1. <?php
  2. namespace Caxy\HtmlDiff;
  3. use Caxy\HtmlDiff\Table\TableDiff;
  4. /**
  5. * Class HtmlDiff
  6. * @package Caxy\HtmlDiff
  7. */
  8. class HtmlDiff extends AbstractDiff
  9. {
  10. /**
  11. * @var array
  12. */
  13. protected $wordIndices;
  14. /**
  15. * @var array
  16. */
  17. protected $oldTables;
  18. /**
  19. * @var array
  20. */
  21. protected $newTables;
  22. /**
  23. * @var array
  24. */
  25. protected $newIsolatedDiffTags;
  26. /**
  27. * @var array
  28. */
  29. protected $oldIsolatedDiffTags;
  30. /**
  31. * @param string $oldText
  32. * @param string $newText
  33. * @param HtmlDiffConfig|null $config
  34. *
  35. * @return self
  36. */
  37. public static function create($oldText, $newText, HtmlDiffConfig $config = null)
  38. {
  39. $diff = new self($oldText, $newText);
  40. if (null !== $config) {
  41. $diff->setConfig($config);
  42. }
  43. return $diff;
  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. return $this;
  56. }
  57. /**
  58. * @param boolean $boolean
  59. * @return HtmlDiff
  60. *
  61. * @deprecated since 0.1.0
  62. */
  63. public function setInsertSpaceInReplace($boolean)
  64. {
  65. $this->config->setInsertSpaceInReplace($boolean);
  66. return $this;
  67. }
  68. /**
  69. * @return boolean
  70. *
  71. * @deprecated since 0.1.0
  72. */
  73. public function getInsertSpaceInReplace()
  74. {
  75. return $this->config->isInsertSpaceInReplace();
  76. }
  77. /**
  78. * @return string
  79. */
  80. public function build()
  81. {
  82. if ($this->hasDiffCache() && $this->getDiffCache()->contains($this->oldText, $this->newText)) {
  83. $this->content = $this->getDiffCache()->fetch($this->oldText, $this->newText);
  84. return $this->content;
  85. }
  86. $this->splitInputsToWords();
  87. $this->replaceIsolatedDiffTags();
  88. $this->indexNewWords();
  89. $operations = $this->operations();
  90. foreach ($operations as $item) {
  91. $this->performOperation( $item );
  92. }
  93. if ($this->hasDiffCache()) {
  94. $this->getDiffCache()->save($this->oldText, $this->newText, $this->content);
  95. }
  96. return $this->content;
  97. }
  98. protected function indexNewWords()
  99. {
  100. $this->wordIndices = array();
  101. foreach ($this->newWords as $i => $word) {
  102. if ( $this->isTag( $word ) ) {
  103. $word = $this->stripTagAttributes( $word );
  104. }
  105. if ( isset( $this->wordIndices[ $word ] ) ) {
  106. $this->wordIndices[ $word ][] = $i;
  107. } else {
  108. $this->wordIndices[ $word ] = array( $i );
  109. }
  110. }
  111. }
  112. protected function replaceIsolatedDiffTags()
  113. {
  114. $this->oldIsolatedDiffTags = $this->createIsolatedDiffTagPlaceholders($this->oldWords);
  115. $this->newIsolatedDiffTags = $this->createIsolatedDiffTagPlaceholders($this->newWords);
  116. }
  117. /**
  118. * @param array $words
  119. *
  120. * @return array
  121. */
  122. protected function createIsolatedDiffTagPlaceholders(&$words)
  123. {
  124. $openIsolatedDiffTags = 0;
  125. $isolatedDiffTagIndicies = array();
  126. $isolatedDiffTagStart = 0;
  127. $currentIsolatedDiffTag = null;
  128. foreach ($words as $index => $word) {
  129. $openIsolatedDiffTag = $this->isOpeningIsolatedDiffTag($word, $currentIsolatedDiffTag);
  130. if ($openIsolatedDiffTag) {
  131. if ($openIsolatedDiffTags === 0) {
  132. $isolatedDiffTagStart = $index;
  133. }
  134. $openIsolatedDiffTags++;
  135. $currentIsolatedDiffTag = $openIsolatedDiffTag;
  136. } elseif ($openIsolatedDiffTags > 0 && $this->isClosingIsolatedDiffTag($word, $currentIsolatedDiffTag)) {
  137. $openIsolatedDiffTags--;
  138. if ($openIsolatedDiffTags == 0) {
  139. $isolatedDiffTagIndicies[] = array ('start' => $isolatedDiffTagStart, 'length' => $index - $isolatedDiffTagStart + 1, 'tagType' => $currentIsolatedDiffTag);
  140. $currentIsolatedDiffTag = null;
  141. }
  142. }
  143. }
  144. $isolatedDiffTagScript = array();
  145. $offset = 0;
  146. foreach ($isolatedDiffTagIndicies as $isolatedDiffTagIndex) {
  147. $start = $isolatedDiffTagIndex['start'] - $offset;
  148. $placeholderString = $this->config->getIsolatedDiffTagPlaceholder($isolatedDiffTagIndex['tagType']);
  149. $isolatedDiffTagScript[$start] = array_splice($words, $start, $isolatedDiffTagIndex['length'], $placeholderString);
  150. $offset += $isolatedDiffTagIndex['length'] - 1;
  151. }
  152. return $isolatedDiffTagScript;
  153. }
  154. /**
  155. * @param string $item
  156. * @param null|string $currentIsolatedDiffTag
  157. *
  158. * @return false|string
  159. */
  160. protected function isOpeningIsolatedDiffTag($item, $currentIsolatedDiffTag = null)
  161. {
  162. $tagsToMatch = $currentIsolatedDiffTag !== null
  163. ? array($currentIsolatedDiffTag => $this->config->getIsolatedDiffTagPlaceholder($currentIsolatedDiffTag))
  164. : $this->config->getIsolatedDiffTags();
  165. foreach ($tagsToMatch as $key => $value) {
  166. if (preg_match("#<".$key."[^>]*>\\s*#iU", $item)) {
  167. return $key;
  168. }
  169. }
  170. return false;
  171. }
  172. /**
  173. * @param string $item
  174. * @param null|string $currentIsolatedDiffTag
  175. *
  176. * @return false|string
  177. */
  178. protected function isClosingIsolatedDiffTag($item, $currentIsolatedDiffTag = null)
  179. {
  180. $tagsToMatch = $currentIsolatedDiffTag !== null
  181. ? array($currentIsolatedDiffTag => $this->config->getIsolatedDiffTagPlaceholder($currentIsolatedDiffTag))
  182. : $this->config->getIsolatedDiffTags();
  183. foreach ($tagsToMatch as $key => $value) {
  184. if (preg_match("#</".$key."[^>]*>\\s*#iU", $item)) {
  185. return $key;
  186. }
  187. }
  188. return false;
  189. }
  190. /**
  191. * @param Operation $operation
  192. */
  193. protected function performOperation($operation)
  194. {
  195. switch ($operation->action) {
  196. case 'equal' :
  197. $this->processEqualOperation( $operation );
  198. break;
  199. case 'delete' :
  200. $this->processDeleteOperation( $operation, "diffdel" );
  201. break;
  202. case 'insert' :
  203. $this->processInsertOperation( $operation, "diffins");
  204. break;
  205. case 'replace':
  206. $this->processReplaceOperation( $operation );
  207. break;
  208. default:
  209. break;
  210. }
  211. }
  212. /**
  213. * @param Operation $operation
  214. */
  215. protected function processReplaceOperation($operation)
  216. {
  217. $this->processDeleteOperation( $operation, "diffmod" );
  218. $this->processInsertOperation( $operation, "diffmod" );
  219. }
  220. /**
  221. * @param Operation $operation
  222. * @param string $cssClass
  223. */
  224. protected function processInsertOperation($operation, $cssClass)
  225. {
  226. $text = array();
  227. foreach ($this->newWords as $pos => $s) {
  228. if ($pos >= $operation->startInNew && $pos < $operation->endInNew) {
  229. if ($this->config->isIsolatedDiffTagPlaceholder($s) && isset($this->newIsolatedDiffTags[$pos])) {
  230. foreach ($this->newIsolatedDiffTags[$pos] as $word) {
  231. $text[] = $word;
  232. }
  233. } else {
  234. $text[] = $s;
  235. }
  236. }
  237. }
  238. $this->insertTag( "ins", $cssClass, $text );
  239. }
  240. /**
  241. * @param Operation $operation
  242. * @param string $cssClass
  243. */
  244. protected function processDeleteOperation($operation, $cssClass)
  245. {
  246. $text = array();
  247. foreach ($this->oldWords as $pos => $s) {
  248. if ($pos >= $operation->startInOld && $pos < $operation->endInOld) {
  249. if ($this->config->isIsolatedDiffTagPlaceholder($s) && isset($this->oldIsolatedDiffTags[$pos])) {
  250. foreach ($this->oldIsolatedDiffTags[$pos] as $word) {
  251. $text[] = $word;
  252. }
  253. } else {
  254. $text[] = $s;
  255. }
  256. }
  257. }
  258. $this->insertTag( "del", $cssClass, $text );
  259. }
  260. /**
  261. * @param Operation $operation
  262. * @param int $pos
  263. * @param string $placeholder
  264. * @param bool $stripWrappingTags
  265. *
  266. * @return string
  267. */
  268. protected function diffIsolatedPlaceholder($operation, $pos, $placeholder, $stripWrappingTags = true)
  269. {
  270. $oldText = implode("", $this->findIsolatedDiffTagsInOld($operation, $pos));
  271. $newText = implode("", $this->newIsolatedDiffTags[$pos]);
  272. if ($this->isListPlaceholder($placeholder)) {
  273. return $this->diffList($oldText, $newText);
  274. } elseif ($this->config->isUseTableDiffing() && $this->isTablePlaceholder($placeholder)) {
  275. return $this->diffTables($oldText, $newText);
  276. } elseif ($this->isLinkPlaceholder($placeholder)) {
  277. return $this->diffLinks($oldText, $newText);
  278. }
  279. return $this->diffElements($oldText, $newText, $stripWrappingTags);
  280. }
  281. /**
  282. * @param string $oldText
  283. * @param string $newText
  284. * @param bool $stripWrappingTags
  285. *
  286. * @return string
  287. */
  288. protected function diffElements($oldText, $newText, $stripWrappingTags = true)
  289. {
  290. $wrapStart = '';
  291. $wrapEnd = '';
  292. if ($stripWrappingTags) {
  293. $pattern = '/(^<[^>]+>)|(<\/[^>]+>$)/i';
  294. $matches = array();
  295. if (preg_match_all($pattern, $newText, $matches)) {
  296. $wrapStart = isset($matches[0][0]) ? $matches[0][0] : '';
  297. $wrapEnd = isset($matches[0][1]) ? $matches[0][1] : '';
  298. }
  299. $oldText = preg_replace($pattern, '', $oldText);
  300. $newText = preg_replace($pattern, '', $newText);
  301. }
  302. $diff = HtmlDiff::create($oldText, $newText, $this->config);
  303. return $wrapStart . $diff->build() . $wrapEnd;
  304. }
  305. /**
  306. * @param string $oldText
  307. * @param string $newText
  308. *
  309. * @return string
  310. */
  311. protected function diffList($oldText, $newText)
  312. {
  313. $diff = ListDiffNew::create($oldText, $newText, $this->config);
  314. return $diff->build();
  315. }
  316. /**
  317. * @param string $oldText
  318. * @param string $newText
  319. *
  320. * @return string
  321. */
  322. protected function diffTables($oldText, $newText)
  323. {
  324. $diff = TableDiff::create($oldText, $newText, $this->config);
  325. return $diff->build();
  326. }
  327. /**
  328. * @param string $oldText
  329. * @param string $newText
  330. *
  331. * @return string
  332. */
  333. protected function diffLinks($oldText, $newText)
  334. {
  335. $oldHref = $this->getAttributeFromTag($oldText, 'href');
  336. $newHref = $this->getAttributeFromTag($newText, 'href');
  337. if ($oldHref != $newHref) {
  338. return sprintf(
  339. '%s%s',
  340. $this->wrapText($oldText, 'del', 'diffmod diff-href'),
  341. $this->wrapText($newText, 'ins', 'diffmod diff-href')
  342. );
  343. }
  344. return $this->diffElements($oldText, $newText);
  345. }
  346. /**
  347. * @param Operation $operation
  348. */
  349. protected function processEqualOperation($operation)
  350. {
  351. $result = array();
  352. foreach ($this->newWords as $pos => $s) {
  353. if ($pos >= $operation->startInNew && $pos < $operation->endInNew) {
  354. if ($this->config->isIsolatedDiffTagPlaceholder($s) && isset($this->newIsolatedDiffTags[$pos])) {
  355. $result[] = $this->diffIsolatedPlaceholder($operation, $pos, $s);
  356. } else {
  357. $result[] = $s;
  358. }
  359. }
  360. }
  361. $this->content .= implode( "", $result );
  362. }
  363. /**
  364. * @param string $text
  365. * @param string $attribute
  366. *
  367. * @return null|string
  368. */
  369. protected function getAttributeFromTag($text, $attribute)
  370. {
  371. $matches = array();
  372. if (preg_match(sprintf('/<a\s+[^>]*%s=([\'"])(.*)\1[^>]*>/i', $attribute), $text, $matches)) {
  373. return $matches[2];
  374. }
  375. return null;
  376. }
  377. /**
  378. * @param string $text
  379. *
  380. * @return bool
  381. */
  382. protected function isListPlaceholder($text)
  383. {
  384. return $this->isPlaceholderType($text, array('ol', 'dl', 'ul'));
  385. }
  386. /**
  387. * @param string $text
  388. *
  389. * @return bool
  390. */
  391. public function isLinkPlaceholder($text)
  392. {
  393. return $this->isPlaceholderType($text, 'a');
  394. }
  395. /**
  396. * @param string $text
  397. * @param array|string $types
  398. * @param bool $strict
  399. *
  400. * @return bool
  401. */
  402. protected function isPlaceholderType($text, $types, $strict = true)
  403. {
  404. if (!is_array($types)) {
  405. $types = array($types);
  406. }
  407. $criteria = array();
  408. foreach ($types as $type) {
  409. if ($this->config->isIsolatedDiffTag($type)) {
  410. $criteria[] = $this->config->getIsolatedDiffTagPlaceholder($type);
  411. } else {
  412. $criteria[] = $type;
  413. }
  414. }
  415. return in_array($text, $criteria, $strict);
  416. }
  417. /**
  418. * @param string $text
  419. *
  420. * @return bool
  421. */
  422. protected function isTablePlaceholder($text)
  423. {
  424. return $this->isPlaceholderType($text, 'table');
  425. }
  426. /**
  427. * @param Operation $operation
  428. * @param int $posInNew
  429. *
  430. * @return array
  431. */
  432. protected function findIsolatedDiffTagsInOld($operation, $posInNew)
  433. {
  434. $offset = $posInNew - $operation->startInNew;
  435. return $this->oldIsolatedDiffTags[$operation->startInOld + $offset];
  436. }
  437. /**
  438. * @param string $tag
  439. * @param string $cssClass
  440. * @param array $words
  441. */
  442. protected function insertTag($tag, $cssClass, &$words)
  443. {
  444. while (true) {
  445. if ( count( $words ) == 0 ) {
  446. break;
  447. }
  448. $nonTags = $this->extractConsecutiveWords( $words, 'noTag' );
  449. $specialCaseTagInjection = '';
  450. $specialCaseTagInjectionIsBefore = false;
  451. if ( count( $nonTags ) != 0 ) {
  452. $text = $this->wrapText( implode( "", $nonTags ), $tag, $cssClass );
  453. $this->content .= $text;
  454. } else {
  455. $firstOrDefault = false;
  456. foreach ($this->config->getSpecialCaseOpeningTags() as $x) {
  457. if ( preg_match( $x, $words[ 0 ] ) ) {
  458. $firstOrDefault = $x;
  459. break;
  460. }
  461. }
  462. if ($firstOrDefault) {
  463. $specialCaseTagInjection = '<ins class="mod">';
  464. if ($tag == "del") {
  465. unset( $words[ 0 ] );
  466. }
  467. } elseif ( array_search( $words[ 0 ], $this->config->getSpecialCaseClosingTags()) !== false ) {
  468. $specialCaseTagInjection = "</ins>";
  469. $specialCaseTagInjectionIsBefore = true;
  470. if ($tag == "del") {
  471. unset( $words[ 0 ] );
  472. }
  473. }
  474. }
  475. if ( count( $words ) == 0 && count( $specialCaseTagInjection ) == 0 ) {
  476. break;
  477. }
  478. if ($specialCaseTagInjectionIsBefore) {
  479. $this->content .= $specialCaseTagInjection . implode( "", $this->extractConsecutiveWords( $words, 'tag' ) );
  480. } else {
  481. $workTag = $this->extractConsecutiveWords( $words, 'tag' );
  482. if ( isset( $workTag[ 0 ] ) && $this->isOpeningTag( $workTag[ 0 ] ) && !$this->isClosingTag( $workTag[ 0 ] ) ) {
  483. if ( strpos( $workTag[ 0 ], 'class=' ) ) {
  484. $workTag[ 0 ] = str_replace( 'class="', 'class="diffmod ', $workTag[ 0 ] );
  485. $workTag[ 0 ] = str_replace( "class='", 'class="diffmod ', $workTag[ 0 ] );
  486. } else {
  487. $workTag[ 0 ] = str_replace( ">", ' class="diffmod">', $workTag[ 0 ] );
  488. }
  489. }
  490. $this->content .= implode( "", $workTag ) . $specialCaseTagInjection;
  491. }
  492. }
  493. }
  494. /**
  495. * @param string $word
  496. * @param string $condition
  497. *
  498. * @return bool
  499. */
  500. protected function checkCondition($word, $condition)
  501. {
  502. return $condition == 'tag' ? $this->isTag( $word ) : !$this->isTag( $word );
  503. }
  504. /**
  505. * @param string $text
  506. * @param string $tagName
  507. * @param string $cssClass
  508. *
  509. * @return string
  510. */
  511. protected function wrapText($text, $tagName, $cssClass)
  512. {
  513. return sprintf( '<%1$s class="%2$s">%3$s</%1$s>', $tagName, $cssClass, $text );
  514. }
  515. /**
  516. * @param array $words
  517. * @param string $condition
  518. *
  519. * @return array
  520. */
  521. protected function extractConsecutiveWords(&$words, $condition)
  522. {
  523. $indexOfFirstTag = null;
  524. $words = array_values($words);
  525. foreach ($words as $i => $word) {
  526. if ( !$this->checkCondition( $word, $condition ) ) {
  527. $indexOfFirstTag = $i;
  528. break;
  529. }
  530. }
  531. if ($indexOfFirstTag !== null) {
  532. $items = array();
  533. foreach ($words as $pos => $s) {
  534. if ($pos >= 0 && $pos < $indexOfFirstTag) {
  535. $items[] = $s;
  536. }
  537. }
  538. if ($indexOfFirstTag > 0) {
  539. array_splice( $words, 0, $indexOfFirstTag );
  540. }
  541. return $items;
  542. } else {
  543. $items = array();
  544. foreach ($words as $pos => $s) {
  545. if ( $pos >= 0 && $pos <= count( $words ) ) {
  546. $items[] = $s;
  547. }
  548. }
  549. array_splice( $words, 0, count( $words ) );
  550. return $items;
  551. }
  552. }
  553. /**
  554. * @param string $item
  555. *
  556. * @return bool
  557. */
  558. protected function isTag($item)
  559. {
  560. return $this->isOpeningTag( $item ) || $this->isClosingTag( $item );
  561. }
  562. /**
  563. * @param string $item
  564. *
  565. * @return bool
  566. */
  567. protected function isOpeningTag($item)
  568. {
  569. return preg_match( "#<[^>]+>\\s*#iU", $item );
  570. }
  571. /**
  572. * @param string $item
  573. *
  574. * @return bool
  575. */
  576. protected function isClosingTag($item)
  577. {
  578. return preg_match( "#</[^>]+>\\s*#iU", $item );
  579. }
  580. /**
  581. * @return Operation[]
  582. */
  583. protected function operations()
  584. {
  585. $positionInOld = 0;
  586. $positionInNew = 0;
  587. $operations = array();
  588. $matches = $this->matchingBlocks();
  589. $matches[] = new Match( count( $this->oldWords ), count( $this->newWords ), 0 );
  590. foreach ($matches as $i => $match) {
  591. $matchStartsAtCurrentPositionInOld = ( $positionInOld == $match->startInOld );
  592. $matchStartsAtCurrentPositionInNew = ( $positionInNew == $match->startInNew );
  593. $action = 'none';
  594. if ($matchStartsAtCurrentPositionInOld == false && $matchStartsAtCurrentPositionInNew == false) {
  595. $action = 'replace';
  596. } elseif ($matchStartsAtCurrentPositionInOld == true && $matchStartsAtCurrentPositionInNew == false) {
  597. $action = 'insert';
  598. } elseif ($matchStartsAtCurrentPositionInOld == false && $matchStartsAtCurrentPositionInNew == true) {
  599. $action = 'delete';
  600. } else { // This occurs if the first few words are the same in both versions
  601. $action = 'none';
  602. }
  603. if ($action != 'none') {
  604. $operations[] = new Operation( $action, $positionInOld, $match->startInOld, $positionInNew, $match->startInNew );
  605. }
  606. if ( count( $match ) != 0 ) {
  607. $operations[] = new Operation( 'equal', $match->startInOld, $match->endInOld(), $match->startInNew, $match->endInNew() );
  608. }
  609. $positionInOld = $match->endInOld();
  610. $positionInNew = $match->endInNew();
  611. }
  612. return $operations;
  613. }
  614. /**
  615. * @return Match[]
  616. */
  617. protected function matchingBlocks()
  618. {
  619. $matchingBlocks = array();
  620. $this->findMatchingBlocks( 0, count( $this->oldWords ), 0, count( $this->newWords ), $matchingBlocks );
  621. return $matchingBlocks;
  622. }
  623. /**
  624. * @param int $startInOld
  625. * @param int $endInOld
  626. * @param int $startInNew
  627. * @param int $endInNew
  628. * @param array $matchingBlocks
  629. */
  630. protected function findMatchingBlocks($startInOld, $endInOld, $startInNew, $endInNew, &$matchingBlocks)
  631. {
  632. $match = $this->findMatch( $startInOld, $endInOld, $startInNew, $endInNew );
  633. if ($match !== null) {
  634. if ($startInOld < $match->startInOld && $startInNew < $match->startInNew) {
  635. $this->findMatchingBlocks( $startInOld, $match->startInOld, $startInNew, $match->startInNew, $matchingBlocks );
  636. }
  637. $matchingBlocks[] = $match;
  638. if ( $match->endInOld() < $endInOld && $match->endInNew() < $endInNew ) {
  639. $this->findMatchingBlocks( $match->endInOld(), $endInOld, $match->endInNew(), $endInNew, $matchingBlocks );
  640. }
  641. }
  642. }
  643. /**
  644. * @param string $word
  645. *
  646. * @return string
  647. */
  648. protected function stripTagAttributes($word)
  649. {
  650. $word = explode( ' ', trim( $word, '<>' ) );
  651. return '<' . $word[ 0 ] . '>';
  652. }
  653. /**
  654. * @param int $startInOld
  655. * @param int $endInOld
  656. * @param int $startInNew
  657. * @param int $endInNew
  658. *
  659. * @return Match|null
  660. */
  661. protected function findMatch($startInOld, $endInOld, $startInNew, $endInNew)
  662. {
  663. $bestMatchInOld = $startInOld;
  664. $bestMatchInNew = $startInNew;
  665. $bestMatchSize = 0;
  666. $matchLengthAt = array();
  667. for ($indexInOld = $startInOld; $indexInOld < $endInOld; $indexInOld++) {
  668. $newMatchLengthAt = array();
  669. $index = $this->oldWords[ $indexInOld ];
  670. if ( $this->isTag( $index ) ) {
  671. $index = $this->stripTagAttributes( $index );
  672. }
  673. if ( !isset( $this->wordIndices[ $index ] ) ) {
  674. $matchLengthAt = $newMatchLengthAt;
  675. continue;
  676. }
  677. foreach ($this->wordIndices[ $index ] as $indexInNew) {
  678. if ($indexInNew < $startInNew) {
  679. continue;
  680. }
  681. if ($indexInNew >= $endInNew) {
  682. break;
  683. }
  684. $newMatchLength = ( isset( $matchLengthAt[ $indexInNew - 1 ] ) ? $matchLengthAt[ $indexInNew - 1 ] : 0 ) + 1;
  685. $newMatchLengthAt[ $indexInNew ] = $newMatchLength;
  686. if ($newMatchLength > $bestMatchSize ||
  687. (
  688. $this->isGroupDiffs() &&
  689. $bestMatchSize > 0 &&
  690. preg_match(
  691. '/^\s+$/',
  692. implode('', array_slice($this->oldWords, $bestMatchInOld, $bestMatchSize))
  693. )
  694. )
  695. ) {
  696. $bestMatchInOld = $indexInOld - $newMatchLength + 1;
  697. $bestMatchInNew = $indexInNew - $newMatchLength + 1;
  698. $bestMatchSize = $newMatchLength;
  699. }
  700. }
  701. $matchLengthAt = $newMatchLengthAt;
  702. }
  703. // Skip match if none found or match consists only of whitespace
  704. if ($bestMatchSize != 0 &&
  705. (
  706. !$this->isGroupDiffs() ||
  707. !preg_match('/^\s+$/', implode('', array_slice($this->oldWords, $bestMatchInOld, $bestMatchSize)))
  708. )
  709. ) {
  710. return new Match($bestMatchInOld, $bestMatchInNew, $bestMatchSize);
  711. }
  712. return null;
  713. }
  714. }