Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
24.41% covered (danger)
24.41%
31 / 127
40.00% covered (danger)
40.00%
2 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
XMLSerializer
24.41% covered (danger)
24.41%
31 / 127
40.00% covered (danger)
40.00%
2 / 5
1361.56
0.00% covered (danger)
0.00%
0 / 1
 encodeHtmlEntities
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 dumpDataAttribs
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
56
 serializeToString
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
506
 accumOffsets
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
272
 serialize
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
9
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Wt2Html;
5
6use Wikimedia\Assert\Assert;
7use Wikimedia\Parsoid\DOM\Comment;
8use Wikimedia\Parsoid\DOM\Document;
9use Wikimedia\Parsoid\DOM\DocumentFragment;
10use Wikimedia\Parsoid\DOM\Element;
11use Wikimedia\Parsoid\DOM\Node;
12use Wikimedia\Parsoid\DOM\Text;
13use Wikimedia\Parsoid\Utils\DOMCompat;
14use Wikimedia\Parsoid\Utils\DOMDataUtils;
15use Wikimedia\Parsoid\Utils\DOMUtils;
16use Wikimedia\Parsoid\Utils\PHPUtils;
17use Wikimedia\Parsoid\Utils\WTUtils;
18use Wikimedia\Parsoid\Wikitext\Consts;
19
20/**
21 * Stand-alone XMLSerializer for DOM3 documents.
22 *
23 * The output is identical to standard XHTML5 DOM serialization, as given by
24 * http://www.w3.org/TR/html-polyglot/
25 * and
26 * https://html.spec.whatwg.org/multipage/syntax.html#serialising-html-fragments
27 * except that we may quote attributes with single quotes, *only* where that would
28 * result in more compact output than the standard double-quoted serialization.
29 */
30class XMLSerializer {
31
32    // https://html.spec.whatwg.org/#serialising-html-fragments
33    private static $alsoSerializeAsVoid = [
34        'basefont' => true,
35        'bgsound' => true,
36        'frame' => true,
37        'keygen' => true
38    ];
39
40    /**
41     * Elements that strip leading newlines
42     * http://www.whatwg.org/specs/web-apps/current-work/multipage/the-end.html#html-fragment-serialization-algorithm
43     */
44    private const NEWLINE_STRIPPING_ELEMENTS = [
45        'pre' => true,
46        'textarea' => true,
47        'listing' => true
48    ];
49
50    private const ENTITY_ENCODINGS = [
51        'single' => [ '<' => '&lt;', '&' => '&amp;', "'" => '&apos;' ],
52        'double' => [ '<' => '&lt;', '&' => '&amp;', '"' => '&quot;' ],
53        'xml' => [ '<' => '&lt;', '&' => '&amp;' ],
54    ];
55
56    /**
57     * HTML entity encoder helper.
58     * Only supports the few entities we'll actually need: <&'"
59     * @param string $raw Input string
60     * @param string $encodeChars Set of characters to encode, "single", "double", or "xml"
61     * @return string
62     */
63    private static function encodeHtmlEntities( string $raw, string $encodeChars ): string {
64        return strtr( $raw, self::ENTITY_ENCODINGS[$encodeChars] );
65    }
66
67    /**
68     * Modify the attribute array, replacing data-object-id with JSON
69     * encoded data.  This is just a debugging hack, not to be confused with
70     * DOMDataUtils::storeDataAttribs()
71     *
72     * @param Element $node
73     * @param array &$attrs
74     * @param bool $keepTmp
75     * @param bool $storeDiffMark
76     */
77    private static function dumpDataAttribs(
78        Element $node, array &$attrs, bool $keepTmp, bool $storeDiffMark
79    ) {
80        if ( !isset( $attrs[DOMDataUtils::DATA_OBJECT_ATTR_NAME] ) ) {
81            return;
82        }
83        $nd = DOMDataUtils::getNodeData( $node );
84        $pd = $nd->parsoid_diff ?? null;
85        if ( $pd && $storeDiffMark ) {
86            $attrs['data-parsoid-diff'] = PHPUtils::jsonEncode( $pd );
87        }
88        $dp = $nd->parsoid;
89        if ( $dp ) {
90            if ( !$keepTmp ) {
91                $dp = clone $dp;
92                // @phan-suppress-next-line PhanTypeObjectUnsetDeclaredProperty
93                unset( $dp->tmp );
94            }
95            $attrs['data-parsoid'] = PHPUtils::jsonEncode( $dp );
96        }
97        $dmw = $nd->mw;
98        if ( $dmw ) {
99            $attrs['data-mw'] = PHPUtils::jsonEncode( $dmw );
100        }
101        unset( $attrs[DOMDataUtils::DATA_OBJECT_ATTR_NAME] );
102    }
103
104    /**
105     * Serialize an HTML DOM3 node to XHTML. The XHTML and associated information will be fed
106     * step-by-step to the callback given in $accum.
107     * @param Node $node
108     * @param array $options See {@link XMLSerializer::serialize()}
109     * @param callable $accum function( $bit, $node, $flag )
110     *   - $bit: (string) piece of HTML code
111     *   - $node: (Node) ??
112     *   - $flag: (string|null) 'start' or 'end' (??)
113     */
114    private static function serializeToString( Node $node, array $options, callable $accum ): void {
115        $smartQuote = $options['smartQuote'];
116        $saveData = $options['saveData'];
117        switch ( $node->nodeType ) {
118            case XML_ELEMENT_NODE:
119                DOMUtils::assertElt( $node );
120                $child = $node->firstChild;
121                $nodeName = DOMCompat::nodeName( $node );
122                $localName = $node->localName;
123                $accum( '<' . $localName, $node );
124                $attrs = DOMUtils::attributes( $node );
125                if ( $saveData ) {
126                    self::dumpDataAttribs( $node, $attrs, $options['keepTmp'], $options['storeDiffMark'] );
127                }
128                foreach ( $attrs as $an => $av ) {
129                    if ( $smartQuote
130                        // More double quotes than single quotes in value?
131                        && substr_count( $av, '"' ) > substr_count( $av, "'" )
132                    ) {
133                        // use single quotes
134                        $accum( ' ' . $an . "='"
135                            . self::encodeHtmlEntities( $av, 'single' ) . "'",
136                            $node );
137                    } else {
138                        // use double quotes
139                        $accum( ' ' . $an . '="'
140                            . self::encodeHtmlEntities( $av, 'double' ) . '"',
141                            $node );
142                    }
143                }
144                if ( $child || (
145                    !isset( Consts::$HTML['VoidTags'][$nodeName] ) &&
146                    !isset( self::$alsoSerializeAsVoid[$nodeName] )
147                ) ) {
148                    $accum( '>', $node, 'start' );
149                    // if is cdata child node
150                    if ( DOMUtils::isRawTextElement( $node ) ) {
151                        // TODO: perform context-sensitive escaping?
152                        // Currently this content is not normally part of our DOM, so
153                        // no problem. If it was, we'd probably have to do some
154                        // tag-specific escaping. Examples:
155                        // * < to \u003c in <script>
156                        // * < to \3c in <style>
157                        // ...
158                        if ( $child ) {
159                            $accum( $child->nodeValue, $node );
160                        }
161                    } else {
162                        if ( $child && isset( self::NEWLINE_STRIPPING_ELEMENTS[$localName] )
163                            && $child->nodeType === XML_TEXT_NODE && str_starts_with( $child->nodeValue, "\n" )
164                        ) {
165                            /* If current node is a pre, textarea, or listing element,
166                             * and the first child node of the element, if any, is a
167                             * Text node whose character data has as its first
168                             * character a U+000A LINE FEED (LF) character, then
169                             * append a U+000A LINE FEED (LF) character. */
170                            $accum( "\n", $node );
171                        }
172                        while ( $child ) {
173                            self::serializeToString( $child, $options, $accum );
174                            $child = $child->nextSibling;
175                        }
176                    }
177                    $accum( '</' . $localName . '>', $node, 'end' );
178                } else {
179                    $accum( '/>', $node, 'end' );
180                }
181                return;
182
183            case XML_DOCUMENT_NODE:
184            case XML_DOCUMENT_FRAG_NODE:
185                '@phan-var Document|DocumentFragment $node';
186                // @var Document|DocumentFragment $node
187                $child = $node->firstChild;
188                while ( $child ) {
189                    self::serializeToString( $child, $options, $accum );
190                    $child = $child->nextSibling;
191                }
192                return;
193
194            case XML_TEXT_NODE:
195                '@phan-var Text $node'; // @var Text $node
196                $accum( self::encodeHtmlEntities( $node->nodeValue, 'xml' ), $node );
197                return;
198
199            case XML_COMMENT_NODE:
200                // According to
201                // http://www.w3.org/TR/DOM-Parsing/#dfn-concept-serialize-xml
202                // we could throw an exception here if node.data would not create
203                // a "well-formed" XML comment.  But we use entity encoding when
204                // we create the comment node to ensure that node.data will always
205                // be okay; see DOMUtils.encodeComment().
206                '@phan-var Comment $node'; // @var Comment $node
207                $accum( '<!--' . $node->nodeValue . '-->', $node );
208                return;
209
210            default:
211                $accum( '??' . DOMCompat::nodeName( $node ), $node );
212        }
213    }
214
215    /**
216     * Add data to an output/memory array (used when serialize() was called with the
217     * captureOffsets flag).
218     * @param array &$out Output array, see {@link self::serialize()} for details on the
219     *   'html' and 'offset' fields. The other fields (positions are 0-based
220     *   and refer to UTF-8 byte indices):
221     *   - start: position in the HTML of the end of the opening tag of <body>
222     *   - last: (Node) last "about sibling" of the currently processed element
223     *     (see {@link WTUtils::getAboutSiblings()}
224     *   - uid: the ID of the element
225     * @param string $bit A piece of the HTML string
226     * @param Node $node The DOM node $bit is a part of
227     * @param ?string $flag 'start' when receiving the final part of the opening tag
228     *   of an element, 'end' when receiving the final part of the closing tag of an element
229     *   or the final part of a self-closing element.
230     */
231    private static function accumOffsets(
232        array &$out, string $bit, Node $node, ?string $flag = null
233    ): void {
234        if ( DOMUtils::atTheTop( $node ) ) {
235            $out['html'] .= $bit;
236            if ( $flag === 'start' ) {
237                $out['start'] = strlen( $out['html'] );
238            } elseif ( $flag === 'end' ) {
239                $out['start'] = null;
240                $out['uid'] = null;
241            }
242        } elseif (
243            !( $node instanceof Element ) || $out['start'] === null ||
244            !DOMUtils::atTheTop( $node->parentNode )
245        ) {
246            // In case you're wondering, out.start may never be set if body
247            // isn't a child of the node passed to serializeToString, or if it
248            // is the node itself but options.innerXML is true.
249            $out['html'] .= $bit;
250            if ( $out['uid'] !== null ) {
251                $out['offsets'][$out['uid']]['html'][1] += strlen( $bit );
252            }
253        } else {
254            $newUid = DOMCompat::getAttribute( $node, 'id' );
255            // Encapsulated siblings don't have generated ids (but may have an id),
256            // so associate them with preceding content.
257            if ( $newUid && $newUid !== $out['uid'] && !$out['last'] ) {
258                if ( !WTUtils::isEncapsulationWrapper( $node ) ) {
259                    $out['uid'] = $newUid;
260                } elseif ( WTUtils::isFirstEncapsulationWrapperNode( $node ) ) {
261                    $about = DOMCompat::getAttribute( $node, 'about' );
262                    $aboutSiblings = WTUtils::getAboutSiblings( $node, $about );
263                    $out['last'] = end( $aboutSiblings );
264                    $out['uid'] = $newUid;
265                }
266            }
267            if ( $out['last'] === $node && $flag === 'end' ) {
268                $out['last'] = null;
269            }
270            Assert::invariant( $out['uid'] !== null, 'uid cannot be null' );
271            if ( !isset( $out['offsets'][$out['uid']] ) ) {
272                $dt = strlen( $out['html'] ) - $out['start'];
273                $out['offsets'][$out['uid']] = [ 'html' => [ $dt, $dt ] ];
274            }
275            $out['html'] .= $bit;
276            $out['offsets'][$out['uid']]['html'][1] += strlen( $bit );
277        }
278    }
279
280    /**
281     * Serialize an HTML DOM3 node to an XHTML string.
282     *
283     * @param Node $node
284     * @param array $options
285     *   - smartQuote (bool, default true): use single quotes for attributes when that's less escaping
286     *   - innerXML (bool, default false): only serialize the contents of $node, exclude $node itself
287     *   - captureOffsets (bool, default false): return tag position data (see below)
288     *   - addDoctype (bool, default true): prepend a DOCTYPE when a full HTML document is serialized
289     *   - saveData (bool, default false): Copy the NodeData into JSON attributes. This is for
290     *     debugging purposes only, the normal code path is to use DOMDataUtils::storeDataAttribs().
291     *   - keepTmp (bool, default false): When saving data, include DataParsoid::$tmp.
292     * @return array An array with the following data:
293     *   - html: the serialized HTML
294     *   - offsets: the start and end position of each element in the HTML, in a
295     *     [ $uid => [ 'html' => [ $start, $end ] ], ... ] format where $uid is the element's
296     *     Parsoid ID, $start is the 0-based index of the first character of the element and
297     *     $end is the index of the first character of the opening tag of the next sibling element,
298     *     or the index of the last character of the element's closing tag if there is no next
299     *     sibling. The positions are relative to the end of the opening <body> tag
300     *     (the DOCTYPE header is not counted), and only present when the captureOffsets flag is set.
301     */
302    public static function serialize( Node $node, array $options = [] ): array {
303        $options += [
304            'smartQuote' => true,
305            'innerXML' => false,
306            'captureOffsets' => false,
307            'addDoctype' => true,
308            'saveData' => false,
309            'keepTmp' => false,
310            'storeDiffMark' => false,
311        ];
312        if ( $node instanceof Document ) {
313            $node = $node->documentElement;
314        }
315        $out = [ 'html' => '', 'offsets' => [], 'start' => null, 'uid' => null, 'last' => null ];
316        $accum = $options['captureOffsets']
317            ? function ( string $bit, Node $node, ?string $flag = null ) use ( &$out ): void {
318                self::accumOffsets( $out, $bit, $node, $flag );
319            }
320            : static function ( string $bit ) use ( &$out ): void {
321                $out['html'] .= $bit;
322            };
323
324        if ( $options['innerXML'] ) {
325            for ( $child = $node->firstChild; $child; $child = $child->nextSibling ) {
326                self::serializeToString( $child, $options, $accum );
327            }
328        } else {
329            self::serializeToString( $node, $options, $accum );
330        }
331        // Ensure there's a doctype for documents.
332        if ( !$options['innerXML'] && DOMCompat::nodeName( $node ) === 'html' && $options['addDoctype'] ) {
333            $out['html'] = "<!DOCTYPE html>\n" . $out['html'];
334        }
335        // Verify UTF-8 soundness (transitional check for PHP port)
336        PHPUtils::assertValidUTF8( $out['html'] );
337        // Drop the bookkeeping
338        unset( $out['start'], $out['uid'], $out['last'] );
339        if ( !$options['captureOffsets'] ) {
340            unset( $out['offsets'] );
341        }
342        return $out;
343    }
344
345}