1<?php
2/**
3 * Portions taken from phpwiki-1.3.3.
4 *
5 * Copyright © 2000, 2001 Geoffrey T. Dairiki <dairiki@dairiki.org>
6 * You may copy this code freely under the conditions of the GPL.
7 *
8 * This program is free software; you can redistribute it and/or modify
9 * it under the terms of the GNU General Public License as published by
10 * the Free Software Foundation; either version 2 of the License, or
11 * (at your option) any later version.
12 *
13 * This program is distributed in the hope that it will be useful,
14 * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 * GNU General Public License for more details.
17 *
18 * You should have received a copy of the GNU General Public License along
19 * with this program; if not, write to the Free Software Foundation, Inc.,
20 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
21 * http://www.gnu.org/copyleft/gpl.html
22 *
23 * @file
24 * @ingroup DifferenceEngine
25 */
26
27/**
28 * MediaWiki default table style diff formatter
29 * @todo document
30 * @newable
31 * @ingroup DifferenceEngine
32 */
33class TableDiffFormatter extends DiffFormatter {
34
35	/**
36	 * Constants for diff sides. Note: these are also used for context lines.
37	 */
38	private const SIDE_DELETED = 'deleted';
39	private const SIDE_ADDED = 'added';
40	private const SIDE_CLASSES = [
41		self::SIDE_DELETED => 'diff-side-deleted',
42		self::SIDE_ADDED => 'diff-side-added'
43	];
44
45	public function __construct() {
46		$this->leadingContextLines = 2;
47		$this->trailingContextLines = 2;
48	}
49
50	/**
51	 * @param string $msg
52	 *
53	 * @return string
54	 */
55	public static function escapeWhiteSpace( $msg ) {
56		$msg = preg_replace( '/^ /m', "\u{00A0} ", $msg );
57		$msg = preg_replace( '/ $/m', " \u{00A0}", $msg );
58		$msg = preg_replace( '/  /', "\u{00A0} ", $msg );
59
60		return $msg;
61	}
62
63	/**
64	 * @param int $xbeg
65	 * @param int $xlen
66	 * @param int $ybeg
67	 * @param int $ylen
68	 *
69	 * @return string
70	 */
71	protected function blockHeader( $xbeg, $xlen, $ybeg, $ylen ) {
72		// '<!--LINE \d+ -->' get replaced by a localised line number
73		// in DifferenceEngine::localiseLineNumbers
74		return Html::rawElement(
75			'tr',
76			[],
77			Html::rawElement(
78				'td',
79				[ 'colspan' => '2',  'class' => 'diff-lineno', 'id' => 'mw-diff-left-l' . $xbeg ],
80				'<!--LINE ' . $xbeg . '-->'
81			) .
82			"\n" .
83			Html::rawElement(
84				'td',
85				[ 'colspan' => '2',  'class' => 'diff-lineno' ],
86				'<!--LINE ' . $ybeg . '-->'
87			)
88		) . "\n";
89	}
90
91	/** @inheritDoc */
92	protected function startBlock( $header ) {
93		$this->writeOutput( $header );
94	}
95
96	/** @inheritDoc */
97	protected function endBlock() {
98	}
99
100	/**
101	 * @param string[] $lines
102	 * @param string $prefix
103	 * @param string $color
104	 */
105	protected function lines( $lines, $prefix = ' ', $color = 'white' ) {
106	}
107
108	/**
109	 * HTML-escape parameter before calling this
110	 *
111	 * @param string $line
112	 *
113	 * @return string
114	 */
115	protected function addedLine( $line ) {
116		return $this->wrapLine( '+', [ 'diff-addedline', $this->getClassForSide( self::SIDE_ADDED ) ], $line );
117	}
118
119	/**
120	 * HTML-escape parameter before calling this
121	 *
122	 * @param string $line
123	 *
124	 * @return string
125	 */
126	protected function deletedLine( $line ) {
127		return $this->wrapLine( '−', [ 'diff-deletedline', $this->getClassForSide( self::SIDE_DELETED ) ], $line );
128	}
129
130	/**
131	 * HTML-escape parameter before calling this
132	 *
133	 * @param string $line
134	 * @param string $side self::SIDE_DELETED or self::SIDE_ADDED
135	 *
136	 * @return string
137	 */
138	protected function contextLine( $line, string $side ) {
139		return $this->wrapLine( '', [ 'diff-context', $this->getClassForSide( $side ) ], $line );
140	}
141
142	/**
143	 * @param string $marker
144	 * @param string|string[] $class A single class or a list of classes
145	 * @param string $line
146	 *
147	 * @return string
148	 */
149	protected function wrapLine( $marker, $class, $line ) {
150		if ( $line !== '' ) {
151			// The <div> wrapper is needed for 'overflow: auto' style to scroll properly
152			$line = Html::rawElement( 'div', [], $this->escapeWhiteSpace( $line ) );
153		} else {
154			$line = Html::element( 'br' );
155		}
156
157		$markerAttrs = [ 'class' => 'diff-marker' ];
158		if ( $marker ) {
159			$markerAttrs['data-marker'] = $marker;
160		}
161
162		return Html::element( 'td', $markerAttrs ) .
163			Html::rawElement( 'td', [ 'class' => $class ], $line );
164	}
165
166	/**
167	 * @param string $side self::SIDE_DELETED or self::SIDE_ADDED
168	 * @return string
169	 */
170	protected function emptyLine( string $side ) {
171		return Html::element( 'td', [ 'colspan' => '2', 'class' => $this->getClassForSide( $side ) ] );
172	}
173
174	/**
175	 * Writes all lines to the output buffer, each enclosed in <tr>.
176	 *
177	 * @param string[] $lines
178	 */
179	protected function added( $lines ) {
180		foreach ( $lines as $line ) {
181			$this->writeOutput(
182				Html::rawElement(
183					'tr',
184					[],
185					$this->emptyLine( self::SIDE_DELETED ) .
186					$this->addedLine(
187						Html::element(
188							'ins',
189							[ 'class' => 'diffchange' ],
190							$line
191						)
192					)
193				) .
194				"\n"
195			);
196		}
197	}
198
199	/**
200	 * Writes all lines to the output buffer, each enclosed in <tr>.
201	 *
202	 * @param string[] $lines
203	 */
204	protected function deleted( $lines ) {
205		foreach ( $lines as $line ) {
206			$this->writeOutput(
207				Html::rawElement(
208					'tr',
209					[],
210					$this->deletedLine(
211						Html::element(
212							'del',
213							[ 'class' => 'diffchange' ],
214							$line
215						)
216					) .
217					$this->emptyLine( self::SIDE_ADDED )
218				) .
219				"\n"
220			);
221		}
222	}
223
224	/**
225	 * Writes all lines to the output buffer, each enclosed in <tr>.
226	 *
227	 * @param string[] $lines
228	 */
229	protected function context( $lines ) {
230		foreach ( $lines as $line ) {
231			$this->writeOutput(
232				Html::rawElement(
233					'tr',
234					[],
235					$this->contextLine( htmlspecialchars( $line ), self::SIDE_DELETED ) .
236					$this->contextLine( htmlspecialchars( $line ), self::SIDE_ADDED )
237				) .
238				"\n"
239			);
240		}
241	}
242
243	/**
244	 * Writes the two sets of lines to the output buffer, each enclosed in <tr>.
245	 *
246	 * @param string[] $orig
247	 * @param string[] $closing
248	 */
249	protected function changed( $orig, $closing ) {
250		$diff = new WordLevelDiff( $orig, $closing );
251		$del = $diff->orig();
252		$add = $diff->closing();
253
254		# Notice that WordLevelDiff returns HTML-escaped output.
255		# Hence, we will be calling addedLine/deletedLine without HTML-escaping.
256
257		$ndel = count( $del );
258		$nadd = count( $add );
259		$n = max( $ndel, $nadd );
260		for ( $i = 0; $i < $n; $i++ ) {
261			$delLine = $i < $ndel ? $this->deletedLine( $del[$i] ) : $this->emptyLine( self::SIDE_DELETED );
262			$addLine = $i < $nadd ? $this->addedLine( $add[$i] ) : $this->emptyLine( self::SIDE_ADDED );
263			$this->writeOutput(
264				Html::rawElement(
265					'tr',
266					[],
267					$delLine . $addLine
268				) .
269				"\n"
270			);
271		}
272	}
273
274	/**
275	 * Get a class for the given diff side, or throw if the side is invalid.
276	 *
277	 * @param string $side self::SIDE_DELETED or self::SIDE_ADDED
278	 * @return string
279	 * @throws InvalidArgumentException
280	 */
281	private function getClassForSide( string $side ): string {
282		if ( !isset( self::SIDE_CLASSES[$side] ) ) {
283			throw new InvalidArgumentException( "Invalid diff side: $side" );
284		}
285		return self::SIDE_CLASSES[$side];
286	}
287}
288