Merge "Add type hint against LinkTarget"
[lhc/web/wiklou.git] / includes / HtmlFormatter.php
1 <?php
2 /**
3 * Performs transformations of HTML by wrapping around libxml2 and working
4 * around its countless bugs.
5 *
6 * This program is free software; you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation; either version 2 of the License, or
9 * (at your option) any later version.
10 *
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License along
17 * with this program; if not, write to the Free Software Foundation, Inc.,
18 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 * http://www.gnu.org/copyleft/gpl.html
20 *
21 * @file
22 */
23 class HtmlFormatter {
24 /**
25 * @var DOMDocument
26 */
27 private $doc;
28
29 private $html;
30 private $itemsToRemove = [];
31 private $elementsToFlatten = [];
32 protected $removeMedia = false;
33
34 /**
35 * Constructor
36 *
37 * @param string $html Text to process
38 */
39 public function __construct( $html ) {
40 $this->html = $html;
41 }
42
43 /**
44 * Turns a chunk of HTML into a proper document
45 * @param string $html
46 * @return string
47 */
48 public static function wrapHTML( $html ) {
49 return '<!doctype html><html><head></head><body>' . $html . '</body></html>';
50 }
51
52 /**
53 * Override this in descendant class to modify HTML after it has been converted from DOM tree
54 * @param string $html HTML to process
55 * @return string Processed HTML
56 */
57 protected function onHtmlReady( $html ) {
58 return $html;
59 }
60
61 /**
62 * @return DOMDocument DOM to manipulate
63 */
64 public function getDoc() {
65 if ( !$this->doc ) {
66 // DOMDocument::loadHTML apparently isn't very good with encodings, so
67 // convert input to ASCII by encoding everything above 128 as entities.
68 if ( function_exists( 'mb_convert_encoding' ) ) {
69 $html = mb_convert_encoding( $this->html, 'HTML-ENTITIES', 'UTF-8' );
70 } else {
71 $html = preg_replace_callback( '/[\x{80}-\x{10ffff}]/u', function ( $m ) {
72 return '&#' . UtfNormal\Utils::utf8ToCodepoint( $m[0] ) . ';';
73 }, $this->html );
74 }
75
76 // Workaround for bug that caused spaces before references
77 // to disappear during processing: https://phabricator.wikimedia.org/T55086
78 // TODO: Please replace with a better fix if one can be found.
79 $html = str_replace( ' <', '&#32;<', $html );
80
81 libxml_use_internal_errors( true );
82 $loader = libxml_disable_entity_loader();
83 $this->doc = new DOMDocument();
84 $this->doc->strictErrorChecking = false;
85 $this->doc->loadHTML( $html );
86 libxml_disable_entity_loader( $loader );
87 libxml_use_internal_errors( false );
88 $this->doc->encoding = 'UTF-8';
89 }
90 return $this->doc;
91 }
92
93 /**
94 * Sets whether images/videos/sounds should be removed from output
95 * @param bool $flag
96 */
97 public function setRemoveMedia( $flag = true ) {
98 $this->removeMedia = $flag;
99 }
100
101 /**
102 * Adds one or more selector of content to remove. A subset of CSS selector
103 * syntax is supported:
104 *
105 * <tag>
106 * <tag>.class
107 * .<class>
108 * #<id>
109 *
110 * @param array|string $selectors Selector(s) of stuff to remove
111 */
112 public function remove( $selectors ) {
113 $this->itemsToRemove = array_merge( $this->itemsToRemove, (array)$selectors );
114 }
115
116 /**
117 * Adds one or more element name to the list to flatten (remove tag, but not its content)
118 * Can accept undelimited regexes
119 *
120 * Note this interface may fail in surprising unexpected ways due to usage of regexes,
121 * so should not be relied on for HTML markup security measures.
122 *
123 * @param array|string $elements Name(s) of tag(s) to flatten
124 */
125 public function flatten( $elements ) {
126 $this->elementsToFlatten = array_merge( $this->elementsToFlatten, (array)$elements );
127 }
128
129 /**
130 * Instructs the formatter to flatten all tags
131 */
132 public function flattenAllTags() {
133 $this->flatten( '[?!]?[a-z0-9]+' );
134 }
135
136 /**
137 * Removes content we've chosen to remove. The text of the removed elements can be
138 * extracted with the getText method.
139 * @return array Array of removed DOMElements
140 */
141 public function filterContent() {
142 $removals = $this->parseItemsToRemove();
143
144 // Bail out early if nothing to do
145 if ( array_reduce( $removals,
146 function ( $carry, $item ) {
147 return $carry && !$item;
148 },
149 true
150 ) ) {
151 return [];
152 }
153
154 $doc = $this->getDoc();
155
156 // Remove tags
157
158 // You can't remove DOMNodes from a DOMNodeList as you're iterating
159 // over them in a foreach loop. It will seemingly leave the internal
160 // iterator on the foreach out of wack and results will be quite
161 // strange. Though, making a queue of items to remove seems to work.
162 $domElemsToRemove = [];
163 foreach ( $removals['TAG'] as $tagToRemove ) {
164 $tagToRemoveNodes = $doc->getElementsByTagName( $tagToRemove );
165 foreach ( $tagToRemoveNodes as $tagToRemoveNode ) {
166 if ( $tagToRemoveNode ) {
167 $domElemsToRemove[] = $tagToRemoveNode;
168 }
169 }
170 }
171 $removed = $this->removeElements( $domElemsToRemove );
172
173 // Elements with named IDs
174 $domElemsToRemove = [];
175 foreach ( $removals['ID'] as $itemToRemove ) {
176 $itemToRemoveNode = $doc->getElementById( $itemToRemove );
177 if ( $itemToRemoveNode ) {
178 $domElemsToRemove[] = $itemToRemoveNode;
179 }
180 }
181 $removed = array_merge( $removed, $this->removeElements( $domElemsToRemove ) );
182
183 // CSS Classes
184 $domElemsToRemove = [];
185 $xpath = new DOMXPath( $doc );
186 foreach ( $removals['CLASS'] as $classToRemove ) {
187 $elements = $xpath->query( '//*[contains(@class, "' . $classToRemove . '")]' );
188
189 /** @var $element DOMElement */
190 foreach ( $elements as $element ) {
191 $classes = $element->getAttribute( 'class' );
192 if ( preg_match( "/\b$classToRemove\b/", $classes ) && $element->parentNode ) {
193 $domElemsToRemove[] = $element;
194 }
195 }
196 }
197 $removed = array_merge( $removed, $this->removeElements( $domElemsToRemove ) );
198
199 // Tags with CSS Classes
200 foreach ( $removals['TAG_CLASS'] as $classToRemove ) {
201 $parts = explode( '.', $classToRemove );
202
203 $elements = $xpath->query(
204 '//' . $parts[0] . '[@class="' . $parts[1] . '"]'
205 );
206 $removed = array_merge( $removed, $this->removeElements( $elements ) );
207 }
208
209 return $removed;
210 }
211
212 /**
213 * Removes a list of elelments from DOMDocument
214 * @param array|DOMNodeList $elements
215 * @return array Array of removed elements
216 */
217 private function removeElements( $elements ) {
218 $list = $elements;
219 if ( $elements instanceof DOMNodeList ) {
220 $list = [];
221 foreach ( $elements as $element ) {
222 $list[] = $element;
223 }
224 }
225 /** @var $element DOMElement */
226 foreach ( $list as $element ) {
227 if ( $element->parentNode ) {
228 $element->parentNode->removeChild( $element );
229 }
230 }
231 return $list;
232 }
233
234 /**
235 * libxml in its usual pointlessness converts many chars to entities - this function
236 * perfoms a reverse conversion
237 * @param string $html
238 * @return string
239 */
240 private function fixLibXML( $html ) {
241 static $replacements;
242 if ( !$replacements ) {
243 // We don't include rules like '&#34;' => '&amp;quot;' because entities had already been
244 // normalized by libxml. Using this function with input not sanitized by libxml is UNSAFE!
245 $replacements = new ReplacementArray( [
246 '&quot;' => '&amp;quot;',
247 '&amp;' => '&amp;amp;',
248 '&lt;' => '&amp;lt;',
249 '&gt;' => '&amp;gt;',
250 ] );
251 }
252 $html = $replacements->replace( $html );
253
254 if ( function_exists( 'mb_convert_encoding' ) ) {
255 // Just in case the conversion in getDoc() above used named
256 // entities that aren't known to html_entity_decode().
257 $html = mb_convert_encoding( $html, 'UTF-8', 'HTML-ENTITIES' );
258 } else {
259 $html = html_entity_decode( $html, ENT_COMPAT, 'utf-8' );
260 }
261 return $html;
262 }
263
264 /**
265 * Performs final transformations and returns resulting HTML. Note that if you want to call this
266 * both without an element and with an element you should call it without an element first. If you
267 * specify the $element in the method it'll change the underlying dom and you won't be able to get
268 * it back.
269 *
270 * @param DOMElement|string|null $element ID of element to get HTML from or
271 * false to get it from the whole tree
272 * @return string Processed HTML
273 */
274 public function getText( $element = null ) {
275
276 if ( $this->doc ) {
277 if ( $element !== null && !( $element instanceof DOMElement ) ) {
278 $element = $this->doc->getElementById( $element );
279 }
280 if ( $element ) {
281 $body = $this->doc->getElementsByTagName( 'body' )->item( 0 );
282 $nodesArray = [];
283 foreach ( $body->childNodes as $node ) {
284 $nodesArray[] = $node;
285 }
286 foreach ( $nodesArray as $nodeArray ) {
287 $body->removeChild( $nodeArray );
288 }
289 $body->appendChild( $element );
290 }
291 $html = $this->doc->saveHTML();
292
293 $html = $this->fixLibXml( $html );
294 if ( wfIsWindows() ) {
295 // Cleanup for CRLF misprocessing of unknown origin on Windows.
296 // If this error continues in the future, please track it down in the
297 // XML code paths if possible and fix there.
298 $html = str_replace( '&#13;', '', $html );
299 }
300 } else {
301 $html = $this->html;
302 }
303 // Remove stuff added by wrapHTML()
304 $html = preg_replace( '/<!--.*?-->|^.*?<body>|<\/body>.*$/s', '', $html );
305 $html = $this->onHtmlReady( $html );
306
307 if ( $this->elementsToFlatten ) {
308 $elements = implode( '|', $this->elementsToFlatten );
309 $html = preg_replace( "#</?($elements)\\b[^>]*>#is", '', $html );
310 }
311
312 return $html;
313 }
314
315 /**
316 * Helper function for parseItemsToRemove(). This function extracts the selector type
317 * and the raw name of a selector from a CSS-style selector string and assigns those
318 * values to parameters passed by reference. For example, if given '#toc' as the
319 * $selector parameter, it will assign 'ID' as the $type and 'toc' as the $rawName.
320 * @param string $selector CSS selector to parse
321 * @param string $type The type of selector (ID, CLASS, TAG_CLASS, or TAG)
322 * @param string $rawName The raw name of the selector
323 * @return bool Whether the selector was successfully recognised
324 * @throws MWException
325 */
326 protected function parseSelector( $selector, &$type, &$rawName ) {
327 if ( strpos( $selector, '.' ) === 0 ) {
328 $type = 'CLASS';
329 $rawName = substr( $selector, 1 );
330 } elseif ( strpos( $selector, '#' ) === 0 ) {
331 $type = 'ID';
332 $rawName = substr( $selector, 1 );
333 } elseif ( strpos( $selector, '.' ) !== 0 && strpos( $selector, '.' ) !== false ) {
334 $type = 'TAG_CLASS';
335 $rawName = $selector;
336 } elseif ( strpos( $selector, '[' ) === false && strpos( $selector, ']' ) === false ) {
337 $type = 'TAG';
338 $rawName = $selector;
339 } else {
340 throw new MWException( __METHOD__ . "(): unrecognized selector '$selector'" );
341 }
342
343 return true;
344 }
345
346 /**
347 * Transforms CSS-style selectors into an internal representation suitable for
348 * processing by filterContent()
349 * @return array
350 */
351 protected function parseItemsToRemove() {
352 $removals = [
353 'ID' => [],
354 'TAG' => [],
355 'CLASS' => [],
356 'TAG_CLASS' => [],
357 ];
358
359 foreach ( $this->itemsToRemove as $itemToRemove ) {
360 $type = '';
361 $rawName = '';
362 if ( $this->parseSelector( $itemToRemove, $type, $rawName ) ) {
363 $removals[$type][] = $rawName;
364 }
365 }
366
367 if ( $this->removeMedia ) {
368 $removals['TAG'][] = 'img';
369 $removals['TAG'][] = 'audio';
370 $removals['TAG'][] = 'video';
371 }
372
373 return $removals;
374 }
375 }