1<?php
2/**
3 * Diff API: WP_Text_Diff_Renderer_Table class
4 *
5 * @package WordPress
6 * @subpackage Diff
7 * @since 4.7.0
8 */
9
10// Don't load directly.
11if ( ! defined( 'ABSPATH' ) ) {
12 die( '-1' );
13}
14
15/**
16 * Table renderer to display the diff lines.
17 *
18 * @since 2.6.0
19 * @uses Text_Diff_Renderer Extends
20 */
21#[AllowDynamicProperties]
22class WP_Text_Diff_Renderer_Table extends Text_Diff_Renderer {
23
24 /**
25 * @see Text_Diff_Renderer::_leading_context_lines
26 * @var int
27 * @since 2.6.0
28 */
29 public $_leading_context_lines = 10000;
30
31 /**
32 * @see Text_Diff_Renderer::_trailing_context_lines
33 * @var int
34 * @since 2.6.0
35 */
36 public $_trailing_context_lines = 10000;
37
38 /**
39 * Title of the item being compared.
40 *
41 * @since 6.4.0 Declared a previously dynamic property.
42 * @var string|null
43 */
44 public $_title;
45
46 /**
47 * Title for the left column.
48 *
49 * @since 6.4.0 Declared a previously dynamic property.
50 * @var string|null
51 */
52 public $_title_left;
53
54 /**
55 * Title for the right column.
56 *
57 * @since 6.4.0 Declared a previously dynamic property.
58 * @var string|null
59 */
60 public $_title_right;
61
62 /**
63 * Threshold for when a diff should be saved or omitted.
64 *
65 * @var float
66 * @since 2.6.0
67 */
68 protected $_diff_threshold = 0.6;
69
70 /**
71 * Inline display helper object name.
72 *
73 * @var string
74 * @since 2.6.0
75 */
76 protected $inline_diff_renderer = 'WP_Text_Diff_Renderer_inline';
77
78 /**
79 * Should we show the split view or not
80 *
81 * @var string
82 * @since 3.6.0
83 */
84 protected $_show_split_view = true;
85
86 protected $compat_fields = array( '_show_split_view', 'inline_diff_renderer', '_diff_threshold' );
87
88 /**
89 * Caches the output of count_chars() in compute_string_distance()
90 *
91 * @var array
92 * @since 5.0.0
93 */
94 protected $count_cache = array();
95
96 /**
97 * Caches the difference calculation in compute_string_distance()
98 *
99 * @var array
100 * @since 5.0.0
101 */
102 protected $difference_cache = array();
103
104 /**
105 * Constructor - Call parent constructor with params array.
106 *
107 * This will set class properties based on the key value pairs in the array.
108 *
109 * @since 2.6.0
110 *
111 * @param array $params
112 */
113 public function __construct( $params = array() ) {
114 parent::__construct( $params );
115 if ( isset( $params['show_split_view'] ) ) {
116 $this->_show_split_view = $params['show_split_view'];
117 }
118 }
119
120 /**
121 * @ignore
122 *
123 * @param string $header
124 * @return string
125 */
126 public function _startBlock( $header ) {
127 return '';
128 }
129
130 /**
131 * @ignore
132 *
133 * @param array $lines
134 * @param string $prefix
135 */
136 public function _lines( $lines, $prefix = ' ' ) {
137 }
138
139 /**
140 * @ignore
141 *
142 * @param string $line HTML-escape the value.
143 * @return string
144 */
145 public function addedLine( $line ) {
146 return "<td class='diff-addedline'><span aria-hidden='true' class='dashicons dashicons-plus'></span><span class='screen-reader-text'>" .
147 /* translators: Hidden accessibility text. */
148 __( 'Added:' ) .
149 " </span>{$line}</td>";
150 }
151
152 /**
153 * @ignore
154 *
155 * @param string $line HTML-escape the value.
156 * @return string
157 */
158 public function deletedLine( $line ) {
159 return "<td class='diff-deletedline'><span aria-hidden='true' class='dashicons dashicons-minus'></span><span class='screen-reader-text'>" .
160 /* translators: Hidden accessibility text. */
161 __( 'Deleted:' ) .
162 " </span>{$line}</td>";
163 }
164
165 /**
166 * @ignore
167 *
168 * @param string $line HTML-escape the value.
169 * @return string
170 */
171 public function contextLine( $line ) {
172 return "<td class='diff-context'><span class='screen-reader-text'>" .
173 /* translators: Hidden accessibility text. */
174 __( 'Unchanged:' ) .
175 " </span>{$line}</td>";
176 }
177
178 /**
179 * @ignore
180 *
181 * @return string
182 */
183 public function emptyLine() {
184 return '<td> </td>';
185 }
186
187 /**
188 * @ignore
189 *
190 * @param array $lines
191 * @param bool $encode
192 * @return string
193 */
194 public function _added( $lines, $encode = true ) {
195 $r = '';
196 foreach ( $lines as $line ) {
197 if ( $encode ) {
198 $processed_line = htmlspecialchars( $line );
199
200 /**
201 * Contextually filters a diffed line.
202 *
203 * Filters TextDiff processing of diffed line. By default, diffs are processed with
204 * htmlspecialchars. Use this filter to remove or change the processing. Passes a context
205 * indicating if the line is added, deleted or unchanged.
206 *
207 * @since 4.1.0
208 *
209 * @param string $processed_line The processed diffed line.
210 * @param string $line The unprocessed diffed line.
211 * @param string $context The line context. Values are 'added', 'deleted' or 'unchanged'.
212 */
213 $line = apply_filters( 'process_text_diff_html', $processed_line, $line, 'added' );
214 }
215
216 if ( $this->_show_split_view ) {
217 $r .= '<tr>' . $this->emptyLine() . $this->addedLine( $line ) . "</tr>\n";
218 } else {
219 $r .= '<tr>' . $this->addedLine( $line ) . "</tr>\n";
220 }
221 }
222 return $r;
223 }
224
225 /**
226 * @ignore
227 *
228 * @param array $lines
229 * @param bool $encode
230 * @return string
231 */
232 public function _deleted( $lines, $encode = true ) {
233 $r = '';
234 foreach ( $lines as $line ) {
235 if ( $encode ) {
236 $processed_line = htmlspecialchars( $line );
237
238 /** This filter is documented in wp-includes/wp-diff.php */
239 $line = apply_filters( 'process_text_diff_html', $processed_line, $line, 'deleted' );
240 }
241 if ( $this->_show_split_view ) {
242 $r .= '<tr>' . $this->deletedLine( $line ) . $this->emptyLine() . "</tr>\n";
243 } else {
244 $r .= '<tr>' . $this->deletedLine( $line ) . "</tr>\n";
245 }
246 }
247 return $r;
248 }
249
250 /**
251 * @ignore
252 *
253 * @param array $lines
254 * @param bool $encode
255 * @return string
256 */
257 public function _context( $lines, $encode = true ) {
258 $r = '';
259 foreach ( $lines as $line ) {
260 if ( $encode ) {
261 $processed_line = htmlspecialchars( $line );
262
263 /** This filter is documented in wp-includes/wp-diff.php */
264 $line = apply_filters( 'process_text_diff_html', $processed_line, $line, 'unchanged' );
265 }
266 if ( $this->_show_split_view ) {
267 $r .= '<tr>' . $this->contextLine( $line ) . $this->contextLine( $line ) . "</tr>\n";
268 } else {
269 $r .= '<tr>' . $this->contextLine( $line ) . "</tr>\n";
270 }
271 }
272 return $r;
273 }
274
275 /**
276 * Process changed lines to do word-by-word diffs for extra highlighting.
277 *
278 * (TRAC style) sometimes these lines can actually be deleted or added rows.
279 * We do additional processing to figure that out
280 *
281 * @since 2.6.0
282 *
283 * @param array $orig
284 * @param array $final
285 * @return string
286 */
287 public function _changed( $orig, $final ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.finalFound
288 $r = '';
289
290 /*
291 * Does the aforementioned additional processing:
292 * *_matches tell what rows are "the same" in orig and final. Those pairs will be diffed to get word changes.
293 * - match is numeric: an index in other column.
294 * - match is 'X': no match. It is a new row.
295 * *_rows are column vectors for the orig column and the final column.
296 * - row >= 0: an index of the $orig or $final array.
297 * - row < 0: a blank row for that column.
298 */
299 list($orig_matches, $final_matches, $orig_rows, $final_rows) = $this->interleave_changed_lines( $orig, $final );
300
301 // These will hold the word changes as determined by an inline diff.
302 $orig_diffs = array();
303 $final_diffs = array();
304
305 // Compute word diffs for each matched pair using the inline diff.
306 foreach ( $orig_matches as $o => $f ) {
307 if ( is_numeric( $o ) && is_numeric( $f ) ) {
308 $text_diff = new Text_Diff( 'auto', array( array( $orig[ $o ] ), array( $final[ $f ] ) ) );
309 $renderer = new $this->inline_diff_renderer();
310 $diff = $renderer->render( $text_diff );
311
312 // If they're too different, don't include any <ins> or <del>'s.
313 if ( preg_match_all( '!(<ins>.*?</ins>|<del>.*?</del>)!', $diff, $diff_matches ) ) {
314 // Length of all text between <ins> or <del>.
315 $stripped_matches = strlen( strip_tags( implode( ' ', $diff_matches[0] ) ) );
316 /*
317 * Since we count length of text between <ins> or <del> (instead of picking just one),
318 * we double the length of chars not in those tags.
319 */
320 $stripped_diff = strlen( strip_tags( $diff ) ) * 2 - $stripped_matches;
321 $diff_ratio = $stripped_matches / $stripped_diff;
322 if ( $diff_ratio > $this->_diff_threshold ) {
323 continue; // Too different. Don't save diffs.
324 }
325 }
326
327 // Un-inline the diffs by removing <del> or <ins>.
328 $orig_diffs[ $o ] = preg_replace( '|<ins>.*?</ins>|', '', $diff );
329 $final_diffs[ $f ] = preg_replace( '|<del>.*?</del>|', '', $diff );
330 }
331 }
332
333 foreach ( array_keys( $orig_rows ) as $row ) {
334 // Both columns have blanks. Ignore them.
335 if ( $orig_rows[ $row ] < 0 && $final_rows[ $row ] < 0 ) {
336 continue;
337 }
338
339 // If we have a word based diff, use it. Otherwise, use the normal line.
340 if ( isset( $orig_diffs[ $orig_rows[ $row ] ] ) ) {
341 $orig_line = $orig_diffs[ $orig_rows[ $row ] ];
342 } elseif ( isset( $orig[ $orig_rows[ $row ] ] ) ) {
343 $orig_line = htmlspecialchars( $orig[ $orig_rows[ $row ] ] );
344 } else {
345 $orig_line = '';
346 }
347
348 if ( isset( $final_diffs[ $final_rows[ $row ] ] ) ) {
349 $final_line = $final_diffs[ $final_rows[ $row ] ];
350 } elseif ( isset( $final[ $final_rows[ $row ] ] ) ) {
351 $final_line = htmlspecialchars( $final[ $final_rows[ $row ] ] );
352 } else {
353 $final_line = '';
354 }
355
356 if ( $orig_rows[ $row ] < 0 ) { // Orig is blank. This is really an added row.
357 $r .= $this->_added( array( $final_line ), false );
358 } elseif ( $final_rows[ $row ] < 0 ) { // Final is blank. This is really a deleted row.
359 $r .= $this->_deleted( array( $orig_line ), false );
360 } else { // A true changed row.
361 if ( $this->_show_split_view ) {
362 $r .= '<tr>' . $this->deletedLine( $orig_line ) . $this->addedLine( $final_line ) . "</tr>\n";
363 } else {
364 $r .= '<tr>' . $this->deletedLine( $orig_line ) . '</tr><tr>' . $this->addedLine( $final_line ) . "</tr>\n";
365 }
366 }
367 }
368
369 return $r;
370 }
371
372 /**
373 * Takes changed blocks and matches which rows in orig turned into which rows in final.
374 *
375 * @since 2.6.0
376 *
377 * @param array $orig Lines of the original version of the text.
378 * @param array $final Lines of the final version of the text.
379 * @return array {
380 * Array containing results of comparing the original text to the final text.
381 *
382 * @type array $orig_matches Associative array of original matches. Index == row
383 * number of `$orig`, value == corresponding row number
384 * of that same line in `$final` or 'x' if there is no
385 * corresponding row (indicating it is a deleted line).
386 * @type array $final_matches Associative array of final matches. Index == row
387 * number of `$final`, value == corresponding row number
388 * of that same line in `$orig` or 'x' if there is no
389 * corresponding row (indicating it is a new line).
390 * @type array $orig_rows Associative array of interleaved rows of `$orig` with
391 * blanks to keep matches aligned with side-by-side diff
392 * of `$final`. A value >= 0 corresponds to index of `$orig`.
393 * Value < 0 indicates a blank row.
394 * @type array $final_rows Associative array of interleaved rows of `$final` with
395 * blanks to keep matches aligned with side-by-side diff
396 * of `$orig`. A value >= 0 corresponds to index of `$final`.
397 * Value < 0 indicates a blank row.
398 * }
399 */
400 public function interleave_changed_lines( $orig, $final ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.finalFound
401
402 // Contains all pairwise string comparisons. Keys are such that this need only be a one dimensional array.
403 $matches = array();
404 foreach ( array_keys( $orig ) as $o ) {
405 foreach ( array_keys( $final ) as $f ) {
406 $matches[ "$o,$f" ] = $this->compute_string_distance( $orig[ $o ], $final[ $f ] );
407 }
408 }
409 asort( $matches ); // Order by string distance.
410
411 $orig_matches = array();
412 $final_matches = array();
413
414 foreach ( $matches as $keys => $difference ) {
415 list($o, $f) = explode( ',', $keys );
416 $o = (int) $o;
417 $f = (int) $f;
418
419 // Already have better matches for these guys.
420 if ( isset( $orig_matches[ $o ] ) && isset( $final_matches[ $f ] ) ) {
421 continue;
422 }
423
424 // First match for these guys. Must be best match.
425 if ( ! isset( $orig_matches[ $o ] ) && ! isset( $final_matches[ $f ] ) ) {
426 $orig_matches[ $o ] = $f;
427 $final_matches[ $f ] = $o;
428 continue;
429 }
430
431 // Best match of this final is already taken? Must mean this final is a new row.
432 if ( isset( $orig_matches[ $o ] ) ) {
433 $final_matches[ $f ] = 'x';
434 } elseif ( isset( $final_matches[ $f ] ) ) {
435 // Best match of this orig is already taken? Must mean this orig is a deleted row.
436 $orig_matches[ $o ] = 'x';
437 }
438 }
439
440 // We read the text in this order.
441 ksort( $orig_matches );
442 ksort( $final_matches );
443
444 // Stores rows and blanks for each column.
445 $orig_rows = array_keys( $orig_matches );
446 $orig_rows_copy = $orig_rows;
447 $final_rows = array_keys( $final_matches );
448
449 /*
450 * Interleaves rows with blanks to keep matches aligned.
451 * We may end up with some extraneous blank rows, but we'll just ignore them later.
452 */
453 foreach ( $orig_rows_copy as $orig_row ) {
454 $final_pos = array_search( $orig_matches[ $orig_row ], $final_rows, true );
455 $orig_pos = (int) array_search( $orig_row, $orig_rows, true );
456
457 if ( false === $final_pos ) { // This orig is paired with a blank final.
458 array_splice( $final_rows, $orig_pos, 0, -1 );
459 } elseif ( $final_pos < $orig_pos ) { // This orig's match is up a ways. Pad final with blank rows.
460 $diff_array = range( -1, $final_pos - $orig_pos );
461 array_splice( $final_rows, $orig_pos, 0, $diff_array );
462 } elseif ( $final_pos > $orig_pos ) { // This orig's match is down a ways. Pad orig with blank rows.
463 $diff_array = range( -1, $orig_pos - $final_pos );
464 array_splice( $orig_rows, $orig_pos, 0, $diff_array );
465 }
466 }
467
468 // Pad the ends with blank rows if the columns aren't the same length.
469 $diff_count = count( $orig_rows ) - count( $final_rows );
470 if ( $diff_count < 0 ) {
471 while ( $diff_count < 0 ) {
472 array_push( $orig_rows, $diff_count++ );
473 }
474 } elseif ( $diff_count > 0 ) {
475 $diff_count = -1 * $diff_count;
476 while ( $diff_count < 0 ) {
477 array_push( $final_rows, $diff_count++ );
478 }
479 }
480
481 return array( $orig_matches, $final_matches, $orig_rows, $final_rows );
482 }
483
484 /**
485 * Computes a number that is intended to reflect the "distance" between two strings.
486 *
487 * @since 2.6.0
488 *
489 * @param string $string1
490 * @param string $string2
491 * @return int
492 */
493 public function compute_string_distance( $string1, $string2 ) {
494 // Use an md5 hash of the strings for a count cache, as it's fast to generate, and collisions aren't a concern.
495 $count_key1 = md5( $string1 );
496 $count_key2 = md5( $string2 );
497
498 // Cache vectors containing character frequency for all chars in each string.
499 if ( ! isset( $this->count_cache[ $count_key1 ] ) ) {
500 $this->count_cache[ $count_key1 ] = count_chars( $string1 );
501 }
502 if ( ! isset( $this->count_cache[ $count_key2 ] ) ) {
503 $this->count_cache[ $count_key2 ] = count_chars( $string2 );
504 }
505
506 $chars1 = $this->count_cache[ $count_key1 ];
507 $chars2 = $this->count_cache[ $count_key2 ];
508
509 $difference_key = md5( implode( ',', $chars1 ) . ':' . implode( ',', $chars2 ) );
510 if ( ! isset( $this->difference_cache[ $difference_key ] ) ) {
511 // L1-norm of difference vector.
512 $this->difference_cache[ $difference_key ] = array_sum( array_map( array( $this, 'difference' ), $chars1, $chars2 ) );
513 }
514
515 $difference = $this->difference_cache[ $difference_key ];
516
517 // $string1 has zero length? Odd. Give huge penalty by not dividing.
518 if ( ! $string1 ) {
519 return $difference;
520 }
521
522 // Return distance per character (of string1).
523 return $difference / strlen( $string1 );
524 }
525
526 /**
527 * @ignore
528 * @since 2.6.0
529 *
530 * @param int $a
531 * @param int $b
532 * @return int
533 */
534 public function difference( $a, $b ) {
535 return abs( $a - $b );
536 }
537
538 /**
539 * Make private properties readable for backward compatibility.
540 *
541 * @since 4.0.0
542 * @since 6.4.0 Getting a dynamic property is deprecated.
543 *
544 * @param string $name Property to get.
545 * @return mixed A declared property's value, else null.
546 */
547 public function __get( $name ) {
548 if ( in_array( $name, $this->compat_fields, true ) ) {
549 return $this->$name;
550 }
551
552 wp_trigger_error(
553 __METHOD__,
554 "The property `{$name}` is not declared. Getting a dynamic property is " .
555 'deprecated since version 6.4.0! Instead, declare the property on the class.',
556 E_USER_DEPRECATED
557 );
558 return null;
559 }
560
561 /**
562 * Make private properties settable for backward compatibility.
563 *
564 * @since 4.0.0
565 * @since 6.4.0 Setting a dynamic property is deprecated.
566 *
567 * @param string $name Property to check if set.
568 * @param mixed $value Property value.
569 */
570 public function __set( $name, $value ) {
571 if ( in_array( $name, $this->compat_fields, true ) ) {
572 $this->$name = $value;
573 return;
574 }
575
576 wp_trigger_error(
577 __METHOD__,
578 "The property `{$name}` is not declared. Setting a dynamic property is " .
579 'deprecated since version 6.4.0! Instead, declare the property on the class.',
580 E_USER_DEPRECATED
581 );
582 }
583
584 /**
585 * Make private properties checkable for backward compatibility.
586 *
587 * @since 4.0.0
588 * @since 6.4.0 Checking a dynamic property is deprecated.
589 *
590 * @param string $name Property to check if set.
591 * @return bool Whether the property is set.
592 */
593 public function __isset( $name ) {
594 if ( in_array( $name, $this->compat_fields, true ) ) {
595 return isset( $this->$name );
596 }
597
598 wp_trigger_error(
599 __METHOD__,
600 "The property `{$name}` is not declared. Checking `isset()` on a dynamic property " .
601 'is deprecated since version 6.4.0! Instead, declare the property on the class.',
602 E_USER_DEPRECATED
603 );
604 return false;
605 }
606
607 /**
608 * Make private properties un-settable for backward compatibility.
609 *
610 * @since 4.0.0
611 * @since 6.4.0 Unsetting a dynamic property is deprecated.
612 *
613 * @param string $name Property to unset.
614 */
615 public function __unset( $name ) {
616 if ( in_array( $name, $this->compat_fields, true ) ) {
617 unset( $this->$name );
618 return;
619 }
620
621 wp_trigger_error(
622 __METHOD__,
623 "A property `{$name}` is not declared. Unsetting a dynamic property is " .
624 'deprecated since version 6.4.0! Instead, declare the property on the class.',
625 E_USER_DEPRECATED
626 );
627 }
628}
629