Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 392 |
|
0.00% |
0 / 13 |
CRAP | |
0.00% |
0 / 1 |
TableFixups | |
0.00% |
0 / 392 |
|
0.00% |
0 / 13 |
24806 | |
0.00% |
0 / 1 |
isSimpleTemplatedSpan | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
12 | |||
fillDSRGap | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
hoistTransclusionInfo | |
0.00% |
0 / 56 |
|
0.00% |
0 / 1 |
182 | |||
collectAttributishContent | |
0.00% |
0 / 37 |
|
0.00% |
0 / 1 |
182 | |||
reparseTemplatedAttributes | |
0.00% |
0 / 32 |
|
0.00% |
0 / 1 |
90 | |||
stripTrailingPipe | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
20 | |||
transferSourceBetweenCells | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
210 | |||
mergeCells | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
42 | |||
convertAttribsToContent | |
0.00% |
0 / 53 |
|
0.00% |
0 / 1 |
342 | |||
reparseWithPreviousCell | |
0.00% |
0 / 58 |
|
0.00% |
0 / 1 |
506 | |||
shouldAbortAttr | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
getReparseType | |
0.00% |
0 / 37 |
|
0.00% |
0 / 1 |
650 | |||
handleTableCellTemplates | |
0.00% |
0 / 63 |
|
0.00% |
0 / 1 |
702 |
1 | <?php |
2 | declare( strict_types = 1 ); |
3 | |
4 | namespace Wikimedia\Parsoid\Wt2Html\DOM\Handlers; |
5 | |
6 | use Wikimedia\Assert\Assert; |
7 | use Wikimedia\Parsoid\Config\Env; |
8 | use Wikimedia\Parsoid\Core\Sanitizer; |
9 | use Wikimedia\Parsoid\DOM\Comment; |
10 | use Wikimedia\Parsoid\DOM\Element; |
11 | use Wikimedia\Parsoid\DOM\Node; |
12 | use Wikimedia\Parsoid\DOM\Text; |
13 | use Wikimedia\Parsoid\NodeData\DataMw; |
14 | use Wikimedia\Parsoid\NodeData\TempData; |
15 | use Wikimedia\Parsoid\NodeData\TemplateInfo; |
16 | use Wikimedia\Parsoid\Tokens\SourceRange; |
17 | use Wikimedia\Parsoid\Utils\DiffDOMUtils; |
18 | use Wikimedia\Parsoid\Utils\DOMCompat; |
19 | use Wikimedia\Parsoid\Utils\DOMDataUtils; |
20 | use Wikimedia\Parsoid\Utils\DOMUtils; |
21 | use Wikimedia\Parsoid\Utils\DTState; |
22 | use Wikimedia\Parsoid\Utils\PHPUtils; |
23 | use Wikimedia\Parsoid\Utils\PipelineUtils; |
24 | use Wikimedia\Parsoid\Utils\Utils; |
25 | use Wikimedia\Parsoid\Utils\WTUtils; |
26 | use Wikimedia\Parsoid\Wt2Html\Frame; |
27 | use Wikimedia\Parsoid\Wt2Html\PegTokenizer; |
28 | |
29 | /** |
30 | * Provides DOMTraverser visitors that fix template-induced interrupted table cell parsing |
31 | * by recombining table cells and/or reparsing table cell content as attributes. |
32 | * - handleTableCellTemplates |
33 | */ |
34 | class TableFixups { |
35 | |
36 | private static function isSimpleTemplatedSpan( Node $node ): bool { |
37 | return DOMCompat::nodeName( $node ) === 'span' && |
38 | DOMUtils::hasTypeOf( $node, 'mw:Transclusion' ) && |
39 | DOMUtils::allChildrenAreTextOrComments( $node ); |
40 | } |
41 | |
42 | /** |
43 | * @param list<string|TemplateInfo> &$parts |
44 | * @param Frame $frame |
45 | * @param int $offset1 |
46 | * @param int $offset2 |
47 | */ |
48 | private static function fillDSRGap( array &$parts, Frame $frame, int $offset1, int $offset2 ): void { |
49 | if ( $offset1 < $offset2 ) { |
50 | $parts[] = PHPUtils::safeSubstr( $frame->getSrcText(), $offset1, $offset2 - $offset1 ); |
51 | } |
52 | } |
53 | |
54 | /** |
55 | * Hoist transclusion information from cell content / attributes |
56 | * onto the cell itself. |
57 | */ |
58 | private static function hoistTransclusionInfo( |
59 | DTState $dtState, array $transclusions, Element $td |
60 | ): void { |
61 | // Initialize dsr for $td |
62 | // In `handleTableCellTemplates`, we're creating a cell w/o dsr info. |
63 | $tdDp = DOMDataUtils::getDataParsoid( $td ); |
64 | if ( !Utils::isValidDSR( $tdDp->dsr ?? null ) ) { |
65 | $tplDp = DOMDataUtils::getDataParsoid( $transclusions[0] ); |
66 | Assert::invariant( Utils::isValidDSR( $tplDp->dsr ?? null ), 'Expected valid DSR' ); |
67 | $tdDp->dsr = clone $tplDp->dsr; |
68 | } |
69 | |
70 | // Build up $parts, $pi to set up the combined transclusion info on $td. |
71 | // Note that content for all but the last template has been swallowed into |
72 | // the attributes of $td. |
73 | $parts = []; |
74 | $pi = []; |
75 | $lastTpl = null; |
76 | $prevDp = null; |
77 | $frame = $dtState->options['frame']; |
78 | |
79 | $index = 0; |
80 | foreach ( $transclusions as $i => $tpl ) { |
81 | $tplDp = DOMDataUtils::getDataParsoid( $tpl ); |
82 | Assert::invariant( Utils::isValidDSR( $tplDp->dsr ?? null ), 'Expected valid DSR' ); |
83 | |
84 | // Plug DSR gaps between transclusions |
85 | if ( !$prevDp ) { |
86 | self::fillDSRGap( $parts, $frame, $tdDp->dsr->start, $tplDp->dsr->start ); |
87 | } else { |
88 | self::fillDSRGap( $parts, $frame, $prevDp->dsr->end, $tplDp->dsr->start ); |
89 | } |
90 | |
91 | // Assimilate $tpl's data-mw and data-parsoid pi info |
92 | $dmw = DOMDataUtils::getDataMw( $tpl ); |
93 | foreach ( $dmw->parts ?? [] as $part ) { |
94 | // Template index is relative to other transclusions. |
95 | // This index is used to extract whitespace information from |
96 | // data-parsoid and that array only includes info for templates. |
97 | // So skip over strings here. |
98 | if ( !is_string( $part ) ) { |
99 | // Cloning is strictly not needed here, but mimicking |
100 | // code in WrapSectionsState.php |
101 | $part = clone $part; |
102 | $part->i = $index++; |
103 | } |
104 | $parts[] = $part; |
105 | } |
106 | PHPUtils::pushArray( $pi, $tplDp->pi ?? [ [] ] ); |
107 | DOMDataUtils::setDataMw( $tpl, null ); |
108 | |
109 | $lastTpl = $tpl; |
110 | $prevDp = $tplDp; |
111 | } |
112 | |
113 | $aboutId = DOMCompat::getAttribute( $lastTpl, 'about' ); |
114 | |
115 | // Hoist transclusion information to $td. |
116 | $td->setAttribute( 'typeof', 'mw:Transclusion' ); |
117 | $td->setAttribute( 'about', $aboutId ); |
118 | |
119 | // Add wikitext for the table cell content following $lastTpl |
120 | self::fillDSRGap( $parts, $frame, $prevDp->dsr->end, $tdDp->dsr->end ); |
121 | |
122 | // Save the new data-mw on the td |
123 | $dmw = new DataMw( [] ); |
124 | $dmw->parts = $parts; |
125 | DOMDataUtils::setDataMw( $td, $dmw ); |
126 | $tdDp->pi = $pi; |
127 | |
128 | // td wraps everything now. |
129 | // Remove template encapsulation from here on. |
130 | // This simplifies the problem of analyzing the <td> |
131 | // for additional fixups (|| Boo || Baz) by potentially |
132 | // invoking 'reparseTemplatedAttributes' on split cells |
133 | // with some modifications. |
134 | $child = $lastTpl; |
135 | |
136 | // Transclusions may be nested in elements in some ugly wikitext so |
137 | // make sure we're starting at a direct descendant of td |
138 | while ( $child->parentNode !== $td ) { |
139 | $child = $child->parentNode; |
140 | } |
141 | |
142 | while ( $child ) { |
143 | if ( |
144 | DOMCompat::nodeName( $child ) === 'span' && |
145 | DOMCompat::getAttribute( $child, 'about' ) === $aboutId |
146 | ) { |
147 | // Remove the encapsulation attributes. If there are no more attributes left, |
148 | // the span wrapper is useless and can be removed. |
149 | $child->removeAttribute( 'about' ); |
150 | $child->removeAttribute( 'typeof' ); |
151 | if ( DOMDataUtils::noAttrs( $child ) ) { |
152 | $next = $child->firstChild ?: $child->nextSibling; |
153 | DOMUtils::migrateChildren( $child, $td, $child ); |
154 | $child->parentNode->removeChild( $child ); |
155 | $child = $next; |
156 | } else { |
157 | $child = $child->nextSibling; |
158 | } |
159 | } else { |
160 | $child = $child->nextSibling; |
161 | } |
162 | } |
163 | |
164 | // $dtState->tplInfo can be null when information is hoisted |
165 | // from children to $td because DOMTraverser hasn't seen the |
166 | // children yet! |
167 | if ( !$dtState->tplInfo ) { |
168 | $dtState->tplInfo = (object)[ |
169 | 'first' => $td, |
170 | 'last' => $td, |
171 | 'clear' => false |
172 | ]; |
173 | } |
174 | } |
175 | |
176 | /** |
177 | * Collect potential attribute content. |
178 | * |
179 | * We expect this to be text nodes without a pipe character followed by one or |
180 | * more nowiki spans, followed by a template encapsulation with pure-text and |
181 | * nowiki content. Collection stops when encountering a pipe character. |
182 | * |
183 | * @param Env $env |
184 | * @param Element $cell known to be <td> / <th> |
185 | * @param ?Element $templateWrapper |
186 | * @return ?array |
187 | */ |
188 | public static function collectAttributishContent( |
189 | Env $env, Element $cell, ?Element $templateWrapper |
190 | ): ?array { |
191 | $buf = []; |
192 | $nowikis = []; |
193 | $transclusions = $templateWrapper ? [ $templateWrapper ] : []; |
194 | |
195 | // Some of this logic could be replaced by DSR-based recovery of |
196 | // wikitext that is outside templates. But since we have to walk over |
197 | // templated content in this fashion anyway, we might as well use the |
198 | // same logic uniformly. |
199 | |
200 | $traverse = static function ( ?Node $child ) use ( |
201 | &$traverse, &$buf, &$nowikis, &$transclusions |
202 | ): bool { |
203 | while ( $child ) { |
204 | if ( $child instanceof Comment ) { |
205 | // Legacy parser strips comments during parsing => drop them. |
206 | } elseif ( $child instanceof Text ) { |
207 | $text = $child->nodeValue; |
208 | $buf[] = $text; |
209 | |
210 | // Are we done accumulating? |
211 | if ( preg_match( '/(?:^|[^|])\|(?:[^|]|$)/D', $text ) ) { |
212 | return true; |
213 | } |
214 | } else { |
215 | '@phan-var Element $child'; /** @var Element $child */ |
216 | if ( DOMUtils::hasTypeOf( $child, 'mw:Transclusion' ) ) { |
217 | $transclusions[] = $child; |
218 | } |
219 | |
220 | if ( WTUtils::isFirstExtensionWrapperNode( $child ) ) { |
221 | // "|" chars in extension content don't trigger table-cell parsing |
222 | // since they have higher precedence in tokenization. The extension |
223 | // content will simply be dropped (but any side effects it had will |
224 | // continue to apply. Ex: <ref> tags might leave an orphaned ref in |
225 | // the <references> section). |
226 | $child = WTUtils::skipOverEncapsulatedContent( $child ); |
227 | continue; |
228 | } elseif ( DOMUtils::hasTypeOf( $child, 'mw:Entity' ) ) { |
229 | // Get entity's wikitext source, not rendered content. |
230 | // " " is "\n" which breaks attribute parsing! |
231 | $buf[] = DOMDataUtils::getDataParsoid( $child )->src ?? $child->textContent; |
232 | } elseif ( DOMUtils::hasTypeOf( $child, 'mw:Nowiki' ) ) { |
233 | // Nowiki span were added to protect otherwise |
234 | // meaningful wikitext chars used in attributes. |
235 | // Save the content and add in a marker to splice out later. |
236 | $nowikis[] = $child->textContent; |
237 | $buf[] = '<nowiki-marker>'; |
238 | } elseif ( self::shouldAbortAttr( $child ) ) { |
239 | return true; |
240 | } else { |
241 | if ( $traverse( $child->firstChild ) ) { |
242 | return true; |
243 | } |
244 | } |
245 | } |
246 | |
247 | $child = $child->nextSibling; |
248 | } |
249 | |
250 | return false; |
251 | }; |
252 | |
253 | if ( $traverse( $cell->firstChild ) ) { |
254 | return [ |
255 | 'txt' => implode( '', $buf ), |
256 | 'nowikis' => $nowikis, |
257 | 'transclusions' => $transclusions, |
258 | ]; |
259 | } else { |
260 | return null; |
261 | } |
262 | } |
263 | |
264 | /** |
265 | * T46498, second part of T52603 |
266 | * |
267 | * Handle wikitext like |
268 | * ``` |
269 | * {| |
270 | * |{{nom|Bar}} |
271 | * |} |
272 | * ``` |
273 | * where nom expands to `style="foo" class="bar"|Bar`. The attributes are |
274 | * tokenized and stripped from the table contents. |
275 | * |
276 | * This method works well for the templates documented in |
277 | * https://en.wikipedia.org/wiki/Template:Table_cell_templates/doc |
278 | * |
279 | * Nevertheless, there are some limitations: |
280 | * - We assume that attributes don't contain wiki markup (apart from <nowiki>) |
281 | * and end up in text or nowiki nodes. |
282 | * - Only a single table cell is produced / opened by the template that |
283 | * contains the attributes. This limitation could be lifted with more |
284 | * aggressive re-parsing if really needed in practice. |
285 | * - There is only a single transclusion in the table cell content. This |
286 | * limitation can be lifted with more advanced data-mw construction. |
287 | * |
288 | * $cell known to be <td> / <th> |
289 | */ |
290 | public static function reparseTemplatedAttributes( |
291 | DTState $dtState, Element $cell, ?Element $templateWrapper |
292 | ): void { |
293 | $env = $dtState->env; |
294 | $frame = $dtState->options['frame']; |
295 | // Collect attribute content and examine it |
296 | $attributishContent = self::collectAttributishContent( $env, $cell, $templateWrapper ); |
297 | if ( !$attributishContent ) { |
298 | return; |
299 | } |
300 | |
301 | /** |
302 | * FIXME: These checks are insufficient. |
303 | * Previous rounds of table fixups might have created this cell without |
304 | * any templated content (the while loop in handleTableCellTemplates). |
305 | * Till we figure out a reliable test for this, we'll reparse attributes always. |
306 | * |
307 | * // This DOM pass is trying to bridge broken parses across |
308 | * // template boundaries. So, if templates aren't involved, |
309 | * // no reason to reparse. |
310 | * if ( count( $attributishContent['transclusions'] ) === 0 && |
311 | * !WTUtils::fromEncapsulatedContent( $cell ) |
312 | * ) { |
313 | * return; |
314 | * } |
315 | */ |
316 | |
317 | $attrText = $attributishContent['txt']; |
318 | if ( !preg_match( '/(^[^|]+\|)([^|]|$)/D', $attrText, $matches ) ) { |
319 | return; |
320 | } |
321 | $attributishPrefix = $matches[1]; |
322 | |
323 | // Splice in nowiki content. We added in <nowiki> markers to prevent the |
324 | // above regexps from matching on nowiki-protected chars. |
325 | if ( str_contains( $attributishPrefix, '<nowiki-marker>' ) ) { |
326 | $attributishPrefix = preg_replace_callback( |
327 | '/<nowiki-marker>/', |
328 | static function ( $unused ) use ( &$attributishContent ) { |
329 | // This is a little tricky. We want to use the content from the |
330 | // nowikis to reparse the string to key/val pairs but the rule, |
331 | // single_cell_table_args, will invariably get tripped up on |
332 | // newlines which, to this point, were shuttled through in the |
333 | // nowiki. Core sanitizer will do this replacement in attr vals |
334 | // so it's a safe normalization to do here. |
335 | return preg_replace( '/\s+/', ' ', array_shift( $attributishContent['nowikis'] ) ); |
336 | }, |
337 | $attributishPrefix |
338 | ); |
339 | } |
340 | |
341 | // re-parse the attributish prefix |
342 | if ( !$dtState->tokenizer ) { |
343 | $dtState->tokenizer = new PegTokenizer( $env ); |
344 | } |
345 | $attributeTokens = $dtState->tokenizer->tokenizeTableCellAttributes( $attributishPrefix, false ); |
346 | |
347 | // No attributes => nothing more to do! |
348 | if ( !$attributeTokens ) { |
349 | return; |
350 | } |
351 | |
352 | // Note that `row_syntax_table_args` (the rule used for tokenizing above) |
353 | // returns an array consisting of [table_attributes, spaces, pipe] |
354 | $attrs = $attributeTokens[0]; |
355 | |
356 | // Sanitize attrs and transfer them to the td node |
357 | Sanitizer::applySanitizedArgs( $env->getSiteConfig(), $cell, $attrs ); |
358 | $cellDp = DOMDataUtils::getDataParsoid( $cell ); |
359 | // Reparsed cells start off as non-mergeable-table cells |
360 | // and preserve that property after reparsing |
361 | $cellDp->setTempFlag( TempData::MERGED_TABLE_CELL ); |
362 | $cellDp->setTempFlag( TempData::NO_ATTRS, false ); |
363 | |
364 | // If the transclusion node was embedded within the td node, |
365 | // lift up the about group to the td node. |
366 | $transclusions = $attributishContent['transclusions']; |
367 | if ( $transclusions && ( $cell !== $transclusions[0] || count( $transclusions ) > 1 ) ) { |
368 | self::hoistTransclusionInfo( $dtState, $transclusions, $cell ); |
369 | } |
370 | |
371 | // Drop content that has been consumed by the reparsed attribute content. |
372 | // NOTE: We serialize and reparse data-object-id attributes as well which |
373 | // ensures stashed data-* attributes continue to be usable. |
374 | // FIXME: This is too naive. What about all the care we showed in `collectAttributishContent`? |
375 | DOMCompat::setInnerHTML( $cell, |
376 | preg_replace( '/^[^|]*\|/', '', DOMCompat::getInnerHTML( $cell ) ) ); |
377 | } |
378 | |
379 | /** |
380 | * $cell's last character is known to be a '|' (for <td>) of '!' (for <th>) |
381 | */ |
382 | private static function stripTrailingPipe( Element $cell ): ?string { |
383 | $lc = $cell->lastChild; |
384 | $txt = ''; |
385 | while ( $lc && !( $lc instanceof Text ) ) { |
386 | $lc = $lc->lastChild; |
387 | } |
388 | |
389 | if ( !$lc ) { |
390 | // FIXME: Is this code reachable? |
391 | return null; |
392 | } |
393 | |
394 | $txt = $lc->textContent; |
395 | $lastCharIndex = strlen( $txt ) - 1; |
396 | $lc->textContent = substr( $txt, 0, $lastCharIndex ); |
397 | return $txt[$lastCharIndex]; |
398 | } |
399 | |
400 | private const PARSOID_ATTRIBUTES = [ |
401 | 'data-object-id', 'typeof', 'about', 'data-parsoid', 'data-mw' |
402 | ]; |
403 | |
404 | /** |
405 | * Ths is called in two cases: |
406 | * (a) when two cells are merged, source is transferred from source |
407 | * to target cell. |
408 | * |
409 | * This is called from mergeCells( .. ) |
410 | * |
411 | * (b) when a pipe (| for td, ! for th) is being transferred from one cell |
412 | * to another making the recepient cell a 'row' syntax cell. In this |
413 | * case, the pipe char could come from content (when the cell has content) |
414 | * OR from the attribute-terminator (when the cell has no content). |
415 | * In the attribute-terminator case, the pipe transfer requires that |
416 | * the openWidth dsr property be decremnted by 1 for the source cell. |
417 | * |
418 | * This is called from reparseWithPreviousCell( .. ) |
419 | */ |
420 | private static function transferSourceBetweenCells( |
421 | string $src, Element $from, Element $to, bool $emptyFromContent |
422 | ): void { |
423 | if ( DOMUtils::hasTypeOf( $to, 'mw:Transclusion' ) ) { |
424 | $dataMW = DOMDataUtils::getDataMw( $to ); |
425 | array_unshift( $dataMW->parts, $src ); |
426 | } |
427 | |
428 | $rowSyntaxChar = DOMCompat::nodeName( $to ) === 'td' ? '|' : '!'; |
429 | $fromDp = DOMDataUtils::getDataParsoid( $from ); |
430 | if ( $rowSyntaxChar === '|' ) { |
431 | unset( $fromDp->startTagSrc ); |
432 | unset( $fromDp->attrSepSrc ); |
433 | } |
434 | |
435 | $hasRowSyntax = false; |
436 | $toDp = DOMDataUtils::getDataParsoid( $to ); |
437 | if ( str_ends_with( $src, $rowSyntaxChar ) ) { |
438 | $hasRowSyntax = true; |
439 | $toDp->stx = 'row'; |
440 | } |
441 | |
442 | $srcLen = strlen( $src ); |
443 | $toDSR = $toDp->dsr ?? null; |
444 | if ( $toDSR ) { |
445 | if ( $toDSR->start ) { |
446 | $toDSR->start -= $srcLen; |
447 | } |
448 | if ( $hasRowSyntax && $toDSR->openWidth ) { |
449 | $toDSR->openWidth += 1; |
450 | } |
451 | } |
452 | |
453 | $fromDSR = $fromDp->dsr ?? null; |
454 | if ( $fromDSR ) { |
455 | if ( $fromDSR->end ) { |
456 | $fromDSR->end -= $srcLen; |
457 | } |
458 | if ( $hasRowSyntax && $fromDSR->openWidth && $emptyFromContent ) { |
459 | $fromDSR->openWidth -= 1; |
460 | } |
461 | } |
462 | } |
463 | |
464 | private static function mergeCells( string $fromSrc, Element $from, Element $to ): void { |
465 | // Update data-mw, DSR if $to is an encapsulation wrapper |
466 | self::transferSourceBetweenCells( $fromSrc, $from, $to, false ); |
467 | |
468 | $identicalCellTypes = DOMCompat::nodeName( $from ) === DOMCompat::nodeName( $to ); |
469 | [ $src, $tgt ] = $identicalCellTypes ? [ $from, $to ] : [ $to, $from ]; |
470 | // For non-identical cell types, $from is the authoritative cell but |
471 | // $to has transclusion attributes. So, we need to migrate data-mw, |
472 | // data-parsoid, etc. as well to the $tgt ($from in this case). |
473 | $ignoreParsoidAttributes = $identicalCellTypes; |
474 | |
475 | foreach ( $src->attributes as $attr ) { |
476 | if ( !$ignoreParsoidAttributes || !in_array( $attr->name, self::PARSOID_ATTRIBUTES, true ) ) { |
477 | $tgt->setAttribute( $attr->name, $attr->value ); |
478 | } |
479 | } |
480 | |
481 | DOMUtils::migrateChildren( $src, $tgt, $identicalCellTypes ? $tgt->firstChild : null ); |
482 | $src->parentNode->removeChild( $src ); |
483 | |
484 | // Combined cells don't merge further |
485 | $tgtDp = DOMDataUtils::getDataParsoid( $tgt ); |
486 | $tgtDp->setTempFlag( TempData::MERGED_TABLE_CELL ); |
487 | $tgtDp->setTempFlag( TempData::NO_ATTRS, false ); |
488 | } |
489 | |
490 | /** |
491 | * Reprocess attribute source as a WT -> HTML transform |
492 | * - If $cell's attributes were templated (mw:ExpandedAttrs typeof), |
493 | * we would have already processed these in AttributeExpander and |
494 | * stuffed it in data-mw. Just pull it out of there. |
495 | * - If not, extract attribute source from the $cell and process it |
496 | * in a wikitext-to-fragment pipeline. |
497 | */ |
498 | private static function convertAttribsToContent( |
499 | Env $env, Frame $frame, Element $cell, bool $leadingPipe, bool $trailingPipe |
500 | ): void { |
501 | $doc = $cell->ownerDocument; |
502 | $cellDp = DOMDataUtils::getDataParsoid( $cell ); |
503 | $cellAttrSrc = $cellDp->getTemp()->attrSrc ?? null; |
504 | |
505 | if ( DOMUtils::matchTypeOf( $cell, "/\bmw:ExpandedAttrs\b/" ) ) { |
506 | DOMUtils::removeTypeOf( $cell, 'mw:ExpandedAttrs' ); |
507 | $dataMw = DOMDataUtils::getDataMw( $cell ); |
508 | unset( $dataMw->attribs ); |
509 | } |
510 | |
511 | // Process attribute wikitext as HTML |
512 | $leadingPipeChar = DOMCompat::nodeName( $cell ) === 'td' ? '|' : '!'; |
513 | // FIXME: Encapsulated doesn't necessarily mean templated |
514 | $fromTpl = WTUtils::fromEncapsulatedContent( $cell ); |
515 | if ( !preg_match( "#['[{<]#", $cellAttrSrc ) ) { |
516 | // Optimization: |
517 | // - SOL constructs like =-*# won't be found here |
518 | // - If no non-sol wikitext constructs, this will just a plain string |
519 | $str = ( $leadingPipe ? $leadingPipeChar : '' ) . |
520 | $cellAttrSrc . |
521 | ( $cellAttrSrc && $trailingPipe ? '|' : '' ); |
522 | $children = [ $doc->createTextNode( $str ) ]; |
523 | } else { |
524 | if ( isset( $cellDp->startTagSrc ) ) { |
525 | $attrSrcOffset = strlen( $cellDp->startTagSrc ); |
526 | } elseif ( ( $cellDp->stx ?? '' ) === 'row' ) { |
527 | $attrSrcOffset = 2; |
528 | } else { |
529 | $attrSrcOffset = 1; |
530 | } |
531 | $frag = PipelineUtils::processContentInPipeline( |
532 | $env, $frame, $cellAttrSrc, [ |
533 | 'sol' => false, |
534 | 'toplevel' => !$fromTpl, |
535 | 'srcOffsets' => $fromTpl ? null : new SourceRange( |
536 | $cellDp->tsr->start + $attrSrcOffset, $cellDp->tsr->end - 1 |
537 | ), |
538 | 'pipelineType' => 'wikitext-to-fragment', |
539 | 'pipelineOpts' => [ 'inlineContext' => true ] |
540 | ] |
541 | ); |
542 | |
543 | if ( $leadingPipe ) { |
544 | $fc = $frag->firstChild; |
545 | if ( $fc instanceof Text ) { |
546 | $fc->textContent = $leadingPipeChar . $fc->textContent; |
547 | } else { |
548 | $frag->insertBefore( $doc->createTextNode( $leadingPipeChar ), $fc ); |
549 | } |
550 | } |
551 | if ( $trailingPipe ) { |
552 | $lc = $frag->lastChild; |
553 | if ( $lc instanceof Text ) { |
554 | $lc->textContent .= '|'; |
555 | } else { |
556 | $frag->appendChild( $doc->createTextNode( '|' ) ); |
557 | } |
558 | } |
559 | $children = iterator_to_array( $frag->childNodes ); |
560 | } |
561 | |
562 | // Append new children |
563 | $sentinel = $cell->firstChild; |
564 | foreach ( $children as $c ) { |
565 | $cell->insertBefore( $c, $sentinel ); |
566 | } |
567 | |
568 | // Remove $cell's attributes |
569 | foreach ( iterator_to_array( $cell->attributes ) as $attr ) { |
570 | if ( !in_array( $attr->name, self::PARSOID_ATTRIBUTES, true ) ) { |
571 | $cell->removeAttribute( $attr->name ); |
572 | } |
573 | } |
574 | |
575 | // Remove shadow attributes to suppress them from wt2wt output! |
576 | unset( $cellDp->a ); |
577 | unset( $cellDp->sa ); |
578 | |
579 | // Update DSR |
580 | if ( !$fromTpl ) { |
581 | $excessDP = strlen( $cellAttrSrc ) + (int)$leadingPipe + (int)$trailingPipe; |
582 | $cellDp->dsr->openWidth -= $excessDP; |
583 | } |
584 | |
585 | // This has no attributes now |
586 | $cellDp->setTempFlag( TempData::NO_ATTRS ); |
587 | } |
588 | |
589 | /** |
590 | * Given: $cell is not a NON_MERGEABLE_TABLE_CELL |
591 | * => $cell syntax is of the form: "|..." or "|..|.." (if <td>) |
592 | * or: "!..." or "!..|.." (if <th>) |
593 | * |
594 | * Examine combined $prev and $cell syntax to see how it should |
595 | * have actually parsed and fix up $prev & $cell appropriately. |
596 | * |
597 | * @param DTState $dtState |
598 | * @param Element $cell |
599 | * @return bool |
600 | */ |
601 | private static function reparseWithPreviousCell( DTState $dtState, Element $cell ): bool { |
602 | // NOTE: The comments in this method always assume |
603 | // <td> && '|', but sometimes <th> & '!' are involved. |
604 | |
605 | $env = $dtState->env; |
606 | $frame = $dtState->options['frame']; |
607 | |
608 | $prev = $cell->previousSibling; |
609 | DOMUtils::assertElt( $prev ); |
610 | |
611 | $prevIsTd = DOMCompat::nodeName( $prev ) === 'td'; |
612 | $prevDp = DOMDataUtils::getDataParsoid( $prev ); |
613 | $prevHasAttrs = !$prevDp->getTempFlag( TempData::NO_ATTRS ); |
614 | |
615 | $cellIsTd = DOMCompat::nodeName( $cell ) === 'td'; |
616 | $cellDp = DOMDataUtils::getDataParsoid( $cell ); |
617 | $cellHasAttrs = !$cellDp->getTempFlag( TempData::NO_ATTRS ); |
618 | |
619 | // Even though we have valid dsr for $prev as a condition of entering |
620 | // here, use tsr start because dsr computation may have expanded the range |
621 | // to include fostered content |
622 | $prevDsr = clone $prevDp->dsr; |
623 | $prevDsr->start = $prevDp->tsr->start; |
624 | |
625 | $prevCellSrc = $prevDsr->substr( $frame->getSrcText() ); |
626 | |
627 | // $prevCellContent = substr( $prevCellSrc, $prevDp->dsr->openWidth ); |
628 | // The following is equivalent because td/th has zero end-tag width |
629 | $prevCellContent = $prevDsr->innerSubstr( $frame->getSrcText() ); |
630 | |
631 | // Parsoid currently doesn't support parsing "|<--cmt-->|" as |
632 | // a "||" which legacy parser does. We won't support this. |
633 | // |
634 | // FIXME: $prev content could have a {{..}} that ended in a "|" |
635 | // and that check is missing here. For now, we won't support this |
636 | // use case unless necessary. |
637 | $prevHasTrailingPipe = |
638 | ( $cellIsTd && str_ends_with( $prevCellContent, "|" ) ) || |
639 | ( !$cellIsTd && !$prevIsTd && str_ends_with( $prevCellContent, "!" ) ); |
640 | |
641 | if ( $prevHasTrailingPipe ) { |
642 | // $prev is of form "..|" |
643 | // => no cell merging |
644 | // strip "|" from $prev |
645 | // migrate "|" to $cell |
646 | $strippedChar = self::stripTrailingPipe( $prev ); |
647 | if ( !$strippedChar ) { |
648 | // We saw these in T384737, it's worth keeping around these conservative |
649 | // checks for the time being |
650 | $env->log( "error/wt2html", "TableFixups: stripTrailingPipe failed." ); |
651 | } else { |
652 | self::transferSourceBetweenCells( |
653 | // $prevHasTrailingPipe => $prevCellContent !== '' => last arg is false |
654 | $strippedChar, $prev, $cell, false /* emptyFromContent */ |
655 | ); |
656 | } |
657 | } elseif ( $prevIsTd && |
658 | $prevDp->getTempFlag( TempData::NON_MERGEABLE_TABLE_CELL ) |
659 | && ( $prevDp->stx ?? '' ) !== 'row' |
660 | ) { |
661 | if ( $prevCellContent !== '' ) { |
662 | // $prev is of form "||.." in SOL position, no attributes, some content |
663 | // Combined wikitext is "||..|.." |
664 | // => <td>..|..</td> |
665 | self::convertAttribsToContent( $env, $frame, $cell, true, true ); |
666 | self::mergeCells( $prevCellSrc, $prev, $cell ); |
667 | } else { |
668 | // $prev is of form "||" in SOL position, no attributes, no content |
669 | // Combined wikitext is "|||.." |
670 | // => <td></td><td..>..</td> |
671 | // migrate "|" to $cell |
672 | self::transferSourceBetweenCells( '|', $prev, $cell, true /* emptyFromContent */ ); // '!' |
673 | } |
674 | } elseif ( !$prevHasAttrs ) { |
675 | // $prev has no attributes and is of form "|.." in SOL posn OR "||.." in non-SOL posn |
676 | // => merge $prev into $cell |
677 | // if $cell had attributes, those become $cell's leading content with a trailing pipe |
678 | if ( $cellIsTd && $cellHasAttrs ) { |
679 | self::convertAttribsToContent( $env, $frame, $cell, false, true ); |
680 | } |
681 | |
682 | // If $cell is a <th>, we need a pipe for us to reprocess $prev's content |
683 | // as $cell's attributes. So, <th> without attributes need special handling. |
684 | if ( !$cellIsTd && !$cellHasAttrs ) { |
685 | // $cell's "!" char should become content now when $prev |
686 | // and $cell are merged below. This code is equivalent to |
687 | // calling convertAttribsToContent( $env, $frame, $cell, true, false ) |
688 | $pipe = $cell->ownerDocument->createTextNode( '!' ); |
689 | $cell->insertBefore( $pipe, $cell->firstChild ); |
690 | } elseif ( $prevCellContent !== '' ) { |
691 | // If $prev cell had content, those become $cell's attributes |
692 | $reparseSrc = $prevCellContent . '|'; |
693 | |
694 | // Reparse the attributish prefix |
695 | if ( !$dtState->tokenizer ) { |
696 | $dtState->tokenizer = new PegTokenizer( $env ); |
697 | } |
698 | $attributeTokens = $dtState->tokenizer->tokenizeTableCellAttributes( $reparseSrc, false ); |
699 | if ( is_array( $attributeTokens ) ) { |
700 | // Note that `row_syntax_table_args` (the rule used for tokenizing above) |
701 | // returns an array consisting of [table_attributes, spaces, pipe] |
702 | $attrs = $attributeTokens[0]; |
703 | Sanitizer::applySanitizedArgs( $env->getSiteConfig(), $cell, $attrs ); |
704 | |
705 | // Remove all $prev's children |
706 | DOMCompat::replaceChildren( $prev ); |
707 | } else { |
708 | // FIXME: Why would this happen? |
709 | // For now, should we just log errors to better understand this? |
710 | // |
711 | // Failed to successfully reparse $reparseSrc as table cell attributes |
712 | // We'll let the cells merge, but we have to convert cell's attributes to content as well |
713 | if ( $cellIsTd ) { |
714 | // The leading pipe should become content since we skipped it |
715 | // in the call to convertAttribsToContent above. |
716 | $pipe = $cell->ownerDocument->createTextNode( '|' ); |
717 | $cell->insertBefore( $pipe, $cell->firstChild ); |
718 | } elseif ( $cellHasAttrs ) { |
719 | // We skipped <th> above |
720 | self::convertAttribsToContent( $env, $frame, $cell, true, true ); |
721 | } |
722 | } |
723 | } |
724 | |
725 | // Merge cells |
726 | self::mergeCells( $prevCellSrc, $prev, $cell ); |
727 | } elseif ( $prevCellContent === '' ) { |
728 | // $prev has attributes and is of form "|..|" in SOL or "||..|" in non-SOL |
729 | // => no cell merging, |
730 | // $prev's attributes are actually its contents |
731 | // migrate "|" to $cell |
732 | self::convertAttribsToContent( $env, $frame, $prev, false, false ); |
733 | self::transferSourceBetweenCells( '|', $prev, $cell, true /* emptyFromContent */ ); |
734 | } else { |
735 | // $prev has attributes and is of form "|..|.." in SOL or "||..|.." in non-SOL |
736 | // => $cell merges into $prev (its attrs & pipes become content) |
737 | self::convertAttribsToContent( $env, $frame, $cell, true, true ); |
738 | self::mergeCells( $prevCellSrc, $prev, $cell ); |
739 | } |
740 | |
741 | return true; |
742 | } |
743 | |
744 | private const NO_REPARSING = 0; |
745 | private const COMBINE_WITH_PREV_CELL = 1; |
746 | private const OTHER_REPARSE = 2; |
747 | |
748 | /** |
749 | * The legacy parser naively aborts attributes on '/\[\[|-\{/' |
750 | * Wikilinks and language converter constructs should follow suit |
751 | */ |
752 | private static function shouldAbortAttr( Element $child ): bool { |
753 | return DOMUtils::matchRel( $child, |
754 | '#^mw:(WikiLink(/Interwiki)?|MediaLink|PageProp/(Category|Language))$#' ) || |
755 | WTUtils::isGeneratedFigure( $child ); |
756 | } |
757 | |
758 | /** |
759 | * $cell is known to be <td>/<th> |
760 | */ |
761 | private static function getReparseType( Element $cell, DTState $dtState ): int { |
762 | $dp = DOMDataUtils::getDataParsoid( $cell ); |
763 | if ( |
764 | !$dp->getTempFlag( TempData::NON_MERGEABLE_TABLE_CELL ) && |
765 | !$dp->getTempFlag( TempData::MERGED_TABLE_CELL ) && |
766 | !$dp->getTempFlag( TempData::FAILED_REPARSE ) && |
767 | // Template wrapping, which happens prior to this pass, may have combined |
768 | // various regions. The important indicator of whether we want to try |
769 | // to combine is if the $cell was the first node of a template. |
770 | $dp->getTempFlag( TempData::AT_SRC_START ) |
771 | ) { |
772 | // Look for opportunities where table cells could combine. This requires |
773 | // $cell to be a templated cell. But, we don't support combining |
774 | // templated cells with other templated cells. So, previous sibling |
775 | // cannot be templated. |
776 | // |
777 | // So, bail out of scenarios where prevDp comes from a template (the checks |
778 | // for isValidDSR( $prevDp-> dsr ) and valid opening tag width catch this. |
779 | $prev = $cell->previousSibling; |
780 | $prevDp = $prev instanceof Element ? DOMDataUtils::getDataParsoid( $prev ) : null; |
781 | if ( $prevDp && |
782 | !WTUtils::hasLiteralHTMLMarker( $prevDp ) && |
783 | Utils::isValidDSR( $prevDp->dsr ?? null, true ) && |
784 | !DOMUtils::hasTypeOf( $prev, 'mw:Transclusion' ) && |
785 | !str_contains( DOMCompat::getInnerHTML( $prev ), "\n" ) |
786 | ) { |
787 | return self::COMBINE_WITH_PREV_CELL; |
788 | } |
789 | } |
790 | |
791 | // FIXME: We're traversing with the outermost encapsulation, but encapsulations |
792 | // can be nested (ie. template in extension content) so the check is insufficient |
793 | $inTplContent = $dtState->tplInfo !== null && |
794 | DOMUtils::hasTypeOf( $dtState->tplInfo->first, 'mw:Transclusion' ); |
795 | |
796 | $cellIsTd = DOMCompat::nodeName( $cell ) === 'td'; |
797 | $testRE = $cellIsTd ? '/[|]/' : '/[!|]/'; |
798 | $child = $cell->firstChild; |
799 | while ( $child ) { |
800 | if ( !$inTplContent && DOMUtils::hasTypeOf( $child, 'mw:Transclusion' ) ) { |
801 | $inTplContent = true; |
802 | } |
803 | |
804 | if ( $inTplContent && |
805 | $child instanceof Text && |
806 | preg_match( $testRE, $child->textContent ) |
807 | ) { |
808 | return self::OTHER_REPARSE; |
809 | } |
810 | |
811 | if ( $child instanceof Element ) { |
812 | if ( WTUtils::isFirstExtensionWrapperNode( $child ) ) { |
813 | // "|" chars in extension/language variant content don't trigger |
814 | // table-cell parsing since they have higher precedence in tokenization |
815 | $child = WTUtils::skipOverEncapsulatedContent( $child ); |
816 | } else { |
817 | if ( self::shouldAbortAttr( $child ) ) { |
818 | return self::NO_REPARSING; |
819 | } |
820 | // FIXME: Ugly for now |
821 | $outerHTML = DOMCompat::getOuterHTML( $child ); |
822 | if ( preg_match( $testRE, $outerHTML ) && |
823 | ( $inTplContent || preg_match( '/"mw:Transclusion"/', $outerHTML ) ) |
824 | ) { |
825 | // A "|" char in the HTML will trigger table cell tokenization. |
826 | // Ex: "| foobar <div> x | y </div>" will split the <div> |
827 | // in table-cell tokenization context. |
828 | return self::OTHER_REPARSE; |
829 | } |
830 | $child = $child->nextSibling; |
831 | } |
832 | } else { |
833 | $child = $child->nextSibling; |
834 | } |
835 | } |
836 | |
837 | return self::NO_REPARSING; |
838 | } |
839 | |
840 | /** |
841 | * In a wikitext-syntax-table-parsing context, the meaning of |
842 | * "|", "||", "!", "!!" is context-sensitive. Additionally, the |
843 | * complete syntactical construct for a table cell (including leading |
844 | * pipes, attributes, and content-separating pipe char) might straddle |
845 | * a template boundary - with some content coming from the top-level and |
846 | * some from a template. |
847 | * |
848 | * This impacts parsing of tables when some cells are templated since |
849 | * Parsoid parses template content independent of top-level content |
850 | * (without any preceding context). This means that Parsoid's table-cell |
851 | * parsing in templated contexts might be incorrect |
852 | * |
853 | * To deal with this, Parsoid implements this table-fixups pass that |
854 | * has to deal with cell-merging and cell-reparsing scenarios. |
855 | * |
856 | * HTML-syntax cells and non-templated cells without any templated content |
857 | * are not subject to this transformation and can be skipped right away. |
858 | * |
859 | * FIXME: This pass can benefit from a customized procsssor rather than |
860 | * piggyback on top of DOMTraverser since the DOM can be significantly |
861 | * mutated in these handlers. |
862 | * |
863 | * @param Element $cell $cell is known to be <td>/<th> |
864 | * @param DTState $dtState |
865 | * @return mixed |
866 | */ |
867 | public static function handleTableCellTemplates( Element $cell, DTState $dtState ) { |
868 | if ( WTUtils::isLiteralHTMLNode( $cell ) ) { |
869 | return true; |
870 | } |
871 | |
872 | // Deal with <th> special case where "!! foo" is parsed as <th>! foo</th> |
873 | // but should have been parsed as <th>foo</th> when not the first child |
874 | if ( DOMCompat::nodeName( $cell ) === 'th' && |
875 | DOMUtils::hasTypeOf( $cell, 'mw:Transclusion' ) && |
876 | // This is checking that previous sibling is not "\n" which would |
877 | // signal that this <th> is on a fresh line and the "!" shouldn't be stripped. |
878 | // If this weren't template output, we would check for "stx" === 'row'. |
879 | // FIXME: Note that ths check is fragile and doesn't work always, but this is |
880 | // the price we pay for Parsoid's independent template parsing! |
881 | $cell->previousSibling instanceof Element |
882 | ) { |
883 | $fc = DiffDOMUtils::firstNonSepChild( $cell ); |
884 | if ( $fc instanceof Text ) { |
885 | $leadingText = $fc->nodeValue; |
886 | if ( str_starts_with( $leadingText, "!" ) ) { |
887 | $fc->nodeValue = substr( $leadingText, 1 ); |
888 | } |
889 | } |
890 | } |
891 | |
892 | $reparseType = self::getReparseType( $cell, $dtState ); |
893 | if ( $reparseType === self::NO_REPARSING ) { |
894 | return true; |
895 | } |
896 | |
897 | $cellDp = DOMDataUtils::getDataParsoid( $cell ); |
898 | if ( $reparseType === self::COMBINE_WITH_PREV_CELL ) { |
899 | if ( self::reparseWithPreviousCell( $dtState, $cell ) ) { |
900 | return true; |
901 | } else { |
902 | // Clear property and retry $cell for other reparses |
903 | // The DOMTraverser will resume the handler on the |
904 | // returned $cell. |
905 | $cellDp->setTempFlag( TempData::FAILED_REPARSE ); |
906 | return $cell; |
907 | } |
908 | } |
909 | |
910 | // If the cell didn't have attrs, extract and reparse templated attrs |
911 | if ( $cellDp->getTempFlag( TempData::NO_ATTRS ) ) { |
912 | $frame = $dtState->options['frame']; |
913 | $templateWrapper = DOMUtils::hasTypeOf( $cell, 'mw:Transclusion' ) ? $cell : null; |
914 | self::reparseTemplatedAttributes( $dtState, $cell, $templateWrapper ); |
915 | } |
916 | |
917 | // Now, examine the <td> to see if it hides additional <td>s |
918 | // and split it up if required. |
919 | // |
920 | // DOMTraverser will process the new cell and invoke |
921 | // handleTableCellTemplates on it which ensures that |
922 | // if any addition attribute fixup or splits are required, |
923 | // they will get done. |
924 | $newCell = null; |
925 | $isTd = DOMCompat::nodeName( $cell ) === 'td'; |
926 | $ownerDoc = $cell->ownerDocument; |
927 | $child = $cell->firstChild; |
928 | while ( $child ) { |
929 | $next = $child->nextSibling; |
930 | |
931 | if ( $newCell ) { |
932 | $newCell->appendChild( $child ); |
933 | } elseif ( $child instanceof Text || self::isSimpleTemplatedSpan( $child ) ) { |
934 | // FIXME: This skips over scenarios like <div>foo||bar</div>. |
935 | $cellName = DOMCompat::nodeName( $cell ); |
936 | $hasSpanWrapper = !( $child instanceof Text ); |
937 | $match = $match1 = $match2 = null; |
938 | |
939 | // Find the first match of || |
940 | preg_match( '/^((?:[^|]*(?:\|[^|])?)*)\|\|([^|].*)?$/D', $child->textContent, $match1 ); |
941 | if ( $isTd ) { |
942 | $match = $match1; |
943 | } else { |
944 | // Find the first match !! |
945 | preg_match( '/^((?:[^!]*(?:\![^!])?)*)\!\!([^!].*)?$/D', $child->textContent, $match2 ); |
946 | |
947 | // Pick the shortest match |
948 | if ( $match1 && $match2 ) { |
949 | $match = strlen( $match1[1] ?? '' ) < strlen( $match2[1] ?? '' ) |
950 | ? $match1 |
951 | : $match2; |
952 | } else { |
953 | $match = $match1 ?: $match2; |
954 | } |
955 | } |
956 | |
957 | if ( $match ) { |
958 | $child->textContent = $match[1] ?? ''; |
959 | |
960 | $newCell = $ownerDoc->createElement( $cellName ); |
961 | if ( $hasSpanWrapper ) { |
962 | /** |
963 | * $hasSpanWrapper above ensures $child is a span. |
964 | * |
965 | * @var Element $child |
966 | */ |
967 | '@phan-var Element $child'; |
968 | // Fix up transclusion wrapping |
969 | $about = DOMCompat::getAttribute( $child, 'about' ); |
970 | self::hoistTransclusionInfo( $dtState, [ $child ], $cell ); |
971 | } else { |
972 | // Refetch the about attribute since 'reparseTemplatedAttributes' |
973 | // might have added one to it. |
974 | $about = DOMCompat::getAttribute( $cell, 'about' ); |
975 | } |
976 | |
977 | // about may not be present if the cell was inside |
978 | // wrapped template content rather than being part |
979 | // of the outermost wrapper. |
980 | if ( $about !== null ) { |
981 | $newCell->setAttribute( 'about', $about ); |
982 | if ( $dtState->tplInfo && $dtState->tplInfo->last === $cell ) { |
983 | $dtState->tplInfo->last = $newCell; |
984 | } |
985 | } |
986 | $newCell->appendChild( $ownerDoc->createTextNode( $match[2] ?? '' ) ); |
987 | $cell->parentNode->insertBefore( $newCell, $cell->nextSibling ); |
988 | |
989 | // Set data-parsoid noAttrs flag |
990 | $newCellDp = DOMDataUtils::getDataParsoid( $newCell ); |
991 | // This new cell has 'row' stx (would be set if the tokenizer had parsed it) |
992 | $newCellDp->stx = 'row'; |
993 | $newCellDp->setTempFlag( TempData::NO_ATTRS ); |
994 | // It is important to set this so that when $newCell is processed by this pass, |
995 | // it won't accidentally recombine again with the previous cell! |
996 | $newCellDp->setTempFlag( TempData::MERGED_TABLE_CELL ); |
997 | } |
998 | } |
999 | |
1000 | $child = $next; |
1001 | } |
1002 | |
1003 | return true; |
1004 | } |
1005 | } |