Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
71.57% covered (warning)
71.57%
73 / 102
88.89% covered (warning)
88.89%
8 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Cleaner
71.57% covered (warning)
71.57%
73 / 102
88.89% covered (warning)
88.89%
8 / 9
60.57
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 cleanHtml
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 createDomDocument
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 addContent
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
11
 matchesRemove
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 nodeHasClass
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 addPartOfContent
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 lastElement
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 cleanHtmlDom
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace MediaWiki\Wikispeech\Segment;
4
5/**
6 * @file
7 * @ingroup Extensions
8 * @license GPL-2.0-or-later
9 */
10
11use DOMComment;
12use DOMDocument;
13use DOMElement;
14use DOMNode;
15use DOMXPath;
16use LogicException;
17use MediaWiki\Wikispeech\Segment\PartOfContent\Link;
18
19/**
20 * Used for cleaning text with HTML markup. The cleaned text is used
21 * as input for `Segmenter`.
22 *
23 * @since 0.1.13 Add `$partOfContent`
24 * @since 0.0.1
25 */
26class Cleaner {
27    /**
28     * An array of tags that should be removed completely during cleaning.
29     *
30     * @var array
31     */
32    private $removeTags;
33
34    /**
35     * An array of tags that should add a segment break during cleaning.
36     *
37     * @var array
38     */
39    private $segmentBreakingTags;
40
41    /**
42     * If true certain tags add extra content that is read before any text.
43     *
44     * @var bool
45     */
46    private $partOfContent;
47
48    /**
49     * An array of `CleanedText`s and `SegmentBreak`s.
50     *
51     * @var SegmentContent[]
52     */
53    private $cleanedContent;
54
55    /**
56     * @since 0.1.13 Add `$partOfContent`
57     * @param array $removeTags An array of tags that should be
58     *  removed completely during cleaning.
59     * @param array $segmentBreakingTags An array of `CleanedText`s
60     *  and `SegmentBreak`s.
61     * @param bool $partOfContent If true certain tags add extra content that is read before any text.
62     */
63    public function __construct(
64        $removeTags,
65        $segmentBreakingTags,
66        bool $partOfContent
67    ) {
68        $this->removeTags = $removeTags;
69        $this->segmentBreakingTags = $segmentBreakingTags;
70        $this->partOfContent = $partOfContent;
71    }
72
73    /**
74     * Clean HTML tags from a string.
75     *
76     * Separates any HTML tags from the text.
77     *
78     * @since 0.0.1
79     * @param string $markedUpText Input text that may contain HTML
80     *  tags.
81     * @return SegmentContent[] Represents the DOM nodes.
82     */
83    public function cleanHtml( $markedUpText ): array {
84        $dom = self::createDomDocument( $markedUpText );
85        $xpath = new DOMXPath( $dom );
86        // Only add elements below the dummy element. These are the
87        // elements from the original HTML.
88        $top = $xpath->evaluate( '/meta/dummy' )->item( 0 );
89        $this->cleanedContent = [];
90        $this->addContent( $top );
91        // Remove any segment break at the start or end of the array,
92        // since they won't do anything.
93        if (
94            $this->cleanedContent &&
95            $this->cleanedContent[0] instanceof SegmentBreak
96        ) {
97            array_shift( $this->cleanedContent );
98        }
99        if ( self::lastElement( $this->cleanedContent ) instanceof SegmentBreak ) {
100            array_pop( $this->cleanedContent );
101        }
102        return $this->cleanedContent;
103    }
104
105    /**
106     * Create a DOMDocument from an HTML string.
107     *
108     * A dummy element is added as top node.
109     *
110     * @since 0.0.1
111     * @param string $markedUpText The string to create the
112     *  DOMDocument.
113     * @return DOMDocument The created DOMDocument.
114     */
115    private static function createDomDocument( $markedUpText ): DOMDocument {
116        $dom = new DOMDocument();
117        // Add encoding information and wrap the input text in a dummy
118        // tag to prevent p tags from being added for text nodes.
119        $wrappedText = '<head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>' .
120            '<dummy>' . $markedUpText . '</dummy></head>';
121        libxml_use_internal_errors( true );
122        $dom->loadHTML(
123            $wrappedText,
124            LIBXML_HTML_NODEFDTD | LIBXML_HTML_NOIMPLIED
125        );
126        return $dom;
127    }
128
129    /**
130     * Recursively add items to the cleaned content.
131     *
132     * Goes through all the child nodes of $node and adds their
133     * content text. Adds segment breaks for appropriate tags.
134     *
135     * @since 0.0.1
136     * @param DOMNode $node The top node to add from.
137     */
138    private function addContent( $node ): void {
139        if ( $node instanceof DOMComment ) {
140            return;
141        }
142
143        if ( $this->matchesRemove( $node ) ) {
144            return;
145        }
146
147        foreach ( $node->childNodes as $child ) {
148            if (
149                !self::lastElement( $this->cleanedContent )
150                    instanceof SegmentBreak &&
151                in_array(
152                    $child->nodeName,
153                    $this->segmentBreakingTags
154                )
155            ) {
156                // Add segment breaks for start tags specified in
157                // the config, unless the previous item is a break
158                // or this is the first item.
159                $this->cleanedContent[] = new SegmentBreak();
160            }
161
162            if ( $child->nodeType == XML_TEXT_NODE ) {
163                // Remove the path to the dummy node and instead
164                // add "." to match when used with context.
165                $path = preg_replace(
166                    '!^/meta/dummy' . '!',
167                    '.',
168                    $child->getNodePath()
169                );
170                $this->cleanedContent[] = new CleanedText( $child->textContent, $path );
171            } else {
172                if ( $this->partOfContent && $child instanceof DOMElement ) {
173                    $this->addPartOfContent( $child );
174                }
175                $this->addContent( $child );
176            }
177            if (
178                !self::lastElement( $this->cleanedContent ) instanceof SegmentBreak &&
179                in_array(
180                    $child->nodeName,
181                    $this->segmentBreakingTags
182                )
183            ) {
184                // Add segment breaks for end tags specified in
185                // the config.
186                $this->cleanedContent[] = new SegmentBreak();
187            }
188        }
189    }
190
191    /**
192     * Check if a node matches criteria for removal.
193     *
194     * The node is compared to the removal criteria from the
195     * configuration, to determine if it should be removed completely.
196     *
197     * @since 0.0.1
198     * @param DOMNode $node The node to check.
199     * @return bool true if the node match removal criteria, otherwise
200     *  false.
201     */
202    private function matchesRemove( $node ): bool {
203        if ( !array_key_exists( $node->nodeName, $this->removeTags ) ) {
204            // The node name isn't found in the removal list.
205            return false;
206        }
207        $removeCriteria = $this->removeTags[$node->nodeName];
208        if ( $removeCriteria === true ) {
209            // Node name is found and there are no extra criteria.
210            return true;
211        } elseif ( is_array( $removeCriteria ) ) {
212            // If there are multiple classes for a tag, check if any
213            // of them match.
214            foreach ( $removeCriteria as $class ) {
215                if ( self::nodeHasClass( $node, $class ) ) {
216                    return true;
217                }
218            }
219        } elseif ( self::nodeHasClass( $node, $removeCriteria ) ) {
220            // Node name and class name match.
221            return true;
222        }
223        return false;
224    }
225
226    /**
227     * Check if a node has a class attribute, containing a string.
228     *
229     * Since this is for checking HTML tag classes, the class
230     * attribute, if present, is assumed to be a string of substrings,
231     * separated by spaces.
232     *
233     * @since 0.0.1
234     * @param DOMNode $node The node to check.
235     * @param string $className The name of the class to check for.
236     * @return bool true if the node's class attribute contain
237     *  $className, otherwise false.
238     */
239    private static function nodeHasClass( $node, $className ): bool {
240        $classNode = $node->attributes->getNamedItem( 'class' );
241        if ( $classNode == null ) {
242            return false;
243        }
244        $classString = $classNode->nodeValue;
245        $nodeClasses = explode( ' ', $classString );
246        return in_array( $className, $nodeClasses );
247    }
248
249    /**
250     * Add an object to `$cleanedContent` if `$element` is of a certain type.
251     *
252     * @since 0.1.13
253     * @param DOMElement $element
254     */
255    private function addPartOfContent( $element ) {
256        // TODO: It may make more sense to have the logic live in the respective classes.
257        if ( $element->nodeName === 'a' ) {
258            $this->cleanedContent[] = new Link();
259        }
260    }
261
262    /**
263     * Get the last element in an array.
264     *
265     * @since 0.0.1
266     * @param array $array The array to get the last element from.
267     * @return mixed|null The last element in the array, null if array is empty.
268     */
269    private static function lastElement( $array ) {
270        if ( !count( $array ) ) {
271            return null;
272        } else {
273            return $array[count( $array ) - 1];
274        }
275    }
276
277    /**
278     * Cleans title and content.
279     *
280     * @since 0.1.10
281     * @param string $displayTitle
282     * @param string $pageContent
283     * @return SegmentContent[] Title and content represented as `CleanedText`s and `SegmentBreak`s
284     * @throws LogicException If segmented title text is not an instance of CleanedText
285     */
286    public function cleanHtmlDom(
287        string $displayTitle,
288        string $pageContent
289    ): array {
290        // Clean HTML.
291        $cleanedText = null;
292        // Parse latest revision, using parser cache.
293        $cleanedText = $this->cleanHtml( $pageContent );
294        // Create a DOM for the title to get the Xpath, in case there
295        // are elements within the title. This happens e.g. when the
296        // title is italicized.
297        $dom = new DOMDocument();
298        $dom->loadHTML(
299            '<h1>' . $displayTitle . '</h1>',
300            LIBXML_HTML_NODEFDTD | LIBXML_HTML_NOIMPLIED
301        );
302        $xpath = new DOMXPath( $dom );
303        $titleSegments = [];
304        $i = 0;
305        foreach ( $this->cleanHtml( $displayTitle ) as $titlePart ) {
306            if ( !$titlePart instanceof CleanedText ) {
307                throw new LogicException(
308                    'Segmented title is not an instance of CleanedText!'
309                );
310            }
311
312            $node = $xpath->evaluate( '//text()' )->item( $i );
313            $titlePart->setPath( '/' . $node->getNodePath() );
314            $titleSegments[] = $titlePart;
315            $titleSegments[] = new SegmentBreak();
316            $i++;
317        }
318        array_pop( $titleSegments );
319        if ( $cleanedText ) {
320            $cleanedText = array_merge(
321                $titleSegments,
322                [ new SegmentBreak() ],
323                $cleanedText
324            );
325        } else {
326            $cleanedText = $titleSegments;
327        }
328        return $cleanedText;
329    }
330
331}