View Javadoc
1   /*
2    * Copyright (c) 2002-2026 Gargoyle Software Inc.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    * https://www.apache.org/licenses/LICENSE-2.0
8    *
9    * Unless required by applicable law or agreed to in writing, software
10   * distributed under the License is distributed on an "AS IS" BASIS,
11   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12   * See the License for the specific language governing permissions and
13   * limitations under the License.
14   */
15  package org.htmlunit.html.serializer;
16  
17  import static org.htmlunit.BrowserVersionFeatures.JS_INNER_TEXT_SELECT_EMPTY;
18  import static org.htmlunit.BrowserVersionFeatures.JS_INNER_TEXT_SVG_NL;
19  
20  import org.htmlunit.BrowserVersion;
21  import org.htmlunit.SgmlPage;
22  import org.htmlunit.WebWindow;
23  import org.htmlunit.css.ComputedCssStyleDeclaration;
24  import org.htmlunit.css.StyleAttributes.Definition;
25  import org.htmlunit.html.DomElement;
26  import org.htmlunit.html.DomNode;
27  import org.htmlunit.html.DomText;
28  import org.htmlunit.html.HtmlBreak;
29  import org.htmlunit.html.HtmlDefinitionTerm;
30  import org.htmlunit.html.HtmlDetails;
31  import org.htmlunit.html.HtmlHead;
32  import org.htmlunit.html.HtmlListItem;
33  import org.htmlunit.html.HtmlNoFrames;
34  import org.htmlunit.html.HtmlOption;
35  import org.htmlunit.html.HtmlParagraph;
36  import org.htmlunit.html.HtmlScript;
37  import org.htmlunit.html.HtmlSelect;
38  import org.htmlunit.html.HtmlStyle;
39  import org.htmlunit.html.HtmlSummary;
40  import org.htmlunit.html.HtmlSvg;
41  import org.htmlunit.html.HtmlTextArea;
42  import org.htmlunit.html.HtmlTitle;
43  import org.htmlunit.html.ScriptElement;
44  import org.htmlunit.html.serializer.HtmlSerializerInnerOuterText.HtmlSerializerTextBuilder.Mode;
45  import org.htmlunit.svg.SvgTitle;
46  import org.htmlunit.util.StringUtils;
47  
48  /**
49   * Special serializer to generate the output we need
50   * for innerText and outerText.
51   *
52   * @author Ronald Brill
53   */
54  public class HtmlSerializerInnerOuterText {
55  
56      private final BrowserVersion browserVersion_;
57  
58      /**
59       * Ctor.
60       *
61       * @param browserVersion the {@link BrowserVersion}
62       */
63      public HtmlSerializerInnerOuterText(final BrowserVersion browserVersion) {
64          super();
65          browserVersion_ = browserVersion;
66      }
67  
68      /**
69       * Converts an HTML node to text.
70       * @param node a node
71       * @return the text representation according to the setting of this serializer
72       */
73      public String asText(final DomNode node) {
74          if (node instanceof HtmlBreak) {
75              return "";
76          }
77  
78          // included scripts are ignored, but if we ask for the script itself....
79          if (node instanceof ScriptElement) {
80              final HtmlSerializerTextBuilder builder = new HtmlSerializerTextBuilder();
81              appendChildren(builder, node, Mode.WHITE_SPACE_NORMAL, false, false);
82              return builder.getText();
83          }
84  
85          // when calling on the title itself we have to output
86          final boolean insideHead = node instanceof HtmlTitle;
87  
88          final HtmlSerializerTextBuilder builder = new HtmlSerializerTextBuilder();
89          appendNode(builder, node, whiteSpaceStyle(node, Mode.WHITE_SPACE_NORMAL), insideHead, false);
90          return builder.getText();
91      }
92  
93      /**
94       * Iterate over all Children and call appendNode() for every.
95       *
96       * @param builder the StringBuilder to add to
97       * @param node the node to process
98       * @param mode the {@link Mode} to use for processing
99       * @param insideHead true if inside head section
100      * @param insideSelect true if inside a select
101      */
102     protected void appendChildren(final HtmlSerializerTextBuilder builder, final DomNode node,
103             final Mode mode, final boolean insideHead, final boolean insideSelect) {
104         for (final DomNode child : node.getChildren()) {
105             appendNode(builder, child, mode, insideHead, insideSelect);
106         }
107     }
108 
109     /**
110      * The core distribution method call the different appendXXX
111      * methods depending on the type of the given node.
112      *
113      * @param builder the StringBuilder to add to
114      * @param node the node to process
115      * @param mode the {@link Mode} to use for processing
116      * @param insideHead true if inside head section
117      * @param insideSelect true if inside a select
118      */
119     protected void appendNode(final HtmlSerializerTextBuilder builder, final DomNode node,
120             final Mode mode, final boolean insideHead, final boolean insideSelect) {
121         if (insideSelect && node instanceof HtmlBreak) {
122             return;
123         }
124 
125         if (node instanceof DomText text) {
126             appendText(builder, text, mode);
127         }
128         else if (node instanceof HtmlBreak break1) {
129             appendBreak(builder, break1);
130         }
131         else if (node instanceof HtmlParagraph paragraph) {
132             appendParagraph(builder, paragraph, mode, insideHead, insideSelect);
133         }
134         else if (node instanceof HtmlListItem item) {
135             appendListItem(builder, item, mode, insideHead, insideSelect);
136         }
137         else if (node instanceof HtmlDetails details) {
138             appendDetails(builder, details, mode, insideHead, insideSelect);
139         }
140         else if (node instanceof HtmlHead) {
141             appendChildren(builder, node, mode, true, insideSelect);
142         }
143         else if (node instanceof HtmlNoFrames) {
144             appendChildren(builder, node, Mode.PLAIN, insideHead, insideSelect);
145         }
146         else if (node instanceof HtmlTitle && !insideHead) {
147             // nothing to do
148         }
149         else if (node instanceof HtmlTextArea) {
150             // nothing to do
151         }
152         else if (node instanceof ScriptElement) {
153             if (insideHead) {
154                 appendChildren(builder, node, mode, insideHead, insideSelect);
155             }
156         }
157         else if (node instanceof HtmlSelect select) {
158             appendSelect(builder, select, mode, insideHead, insideSelect);
159         }
160         else if (node instanceof HtmlDefinitionTerm item) {
161             appendDefinitionTerm(builder, item, mode, insideHead, insideSelect);
162         }
163         else if (node instanceof HtmlSvg) {
164             if (browserVersion_.hasFeature(JS_INNER_TEXT_SVG_NL)) {
165                 builder.appendRequiredLineBreak();
166                 appendChildren(builder, node, mode, insideHead, insideSelect);
167                 builder.appendRequiredLineBreak();
168             }
169             else {
170                 appendChildren(builder, node, mode, insideHead, insideSelect);
171             }
172         }
173         else if (node instanceof SvgTitle) {
174             // nothing to do
175         }
176         else {
177             appendChildren(builder, node, mode, insideHead, insideSelect);
178         }
179     }
180 
181     /**
182      * Process {@link DomText}.
183      *
184      * @param builder the StringBuilder to add to
185      * @param domText the target to process
186      * @param mode the {@link Mode} to use for processing
187      */
188     protected void appendText(final HtmlSerializerTextBuilder builder, final DomText domText, final Mode mode) {
189         final DomNode parent = domText.getParentNode();
190         if (parent instanceof HtmlTitle
191                 || parent instanceof HtmlStyle
192                 || parent instanceof HtmlScript) {
193             builder.append(domText.getData(), Mode.PLAIN);
194             return;
195         }
196 
197         if (parent == null
198                 || parent instanceof HtmlNoFrames
199                 || parent.isDisplayed()) {
200             builder.append(domText.getData(), mode);
201         }
202     }
203 
204     /**
205      * Process {@link HtmlBreak}.
206      *
207      * @param builder the StringBuilder to add to
208      * @param htmlBreak the target to process
209      */
210     protected void appendBreak(final HtmlSerializerTextBuilder builder,
211             final HtmlBreak htmlBreak) {
212         builder.appendRequiredLineBreak();
213     }
214 
215     /**
216      * Process {@link HtmlParagraph}.
217      *
218      * @param builder the StringBuilder to add to
219      * @param htmlParagraph the target to process
220      * @param mode the {@link Mode} to use for processing
221      * @param insideHead true if inside head section
222      * @param insideSelect true if inside a select
223      */
224     protected void appendParagraph(final HtmlSerializerTextBuilder builder,
225             final HtmlParagraph htmlParagraph, final Mode mode, final boolean insideHead, final boolean insideSelect) {
226         builder.appendRequiredLineBreak();
227         appendChildren(builder, htmlParagraph, mode, insideHead, insideSelect);
228         builder.appendRequiredLineBreak();
229     }
230 
231     /**
232      * Process {@link HtmlListItem}.
233      *
234      * @param builder the StringBuilder to add to
235      * @param htmlListItem the target to process
236      * @param mode the {@link Mode} to use for processing
237      * @param insideHead true if inside head section
238      * @param insideSelect true if inside a select
239      */
240     protected void appendListItem(final HtmlSerializerTextBuilder builder,
241             final HtmlListItem htmlListItem, final Mode mode, final boolean insideHead, final boolean insideSelect) {
242         builder.appendRequiredLineBreak();
243         appendChildren(builder, htmlListItem, mode, insideHead, insideSelect);
244         builder.appendRequiredLineBreak();
245     }
246 
247     /**
248      * Process {@link HtmlDetails}.
249      * @param builder the StringBuilder to add to
250      * @param htmlDetails the target to process
251      * @param mode the {@link Mode} to use for processing
252      * @param insideHead true if inside head section
253      * @param insideSelect true if inside a select
254      */
255     protected void appendDetails(final HtmlSerializerTextBuilder builder,
256                     final HtmlDetails htmlDetails, final Mode mode,
257                     final boolean insideHead, final boolean insideSelect) {
258         if (htmlDetails.isOpen()) {
259             appendChildren(builder, htmlDetails, mode, insideHead, insideSelect);
260             return;
261         }
262 
263         for (final DomNode child : htmlDetails.getChildren()) {
264             if (child instanceof HtmlSummary) {
265                 appendNode(builder, child, mode, insideHead, insideSelect);
266             }
267         }
268     }
269 
270     /**
271      * Process {@link HtmlDefinitionTerm}.
272      *
273      * @param builder the StringBuilder to add to
274      * @param htmlDefinitionTerm the target to process
275      * @param mode the {@link Mode} to use for processing
276      * @param insideHead true if inside head section
277      * @param insideSelect true if inside a select
278      */
279     protected void appendDefinitionTerm(final HtmlSerializerTextBuilder builder,
280             final HtmlDefinitionTerm htmlDefinitionTerm, final Mode mode,
281             final boolean insideHead, final boolean insideSelect) {
282         builder.appendRequiredLineBreak();
283         appendChildren(builder, htmlDefinitionTerm, mode, insideHead, insideSelect);
284         builder.appendRequiredLineBreak();
285     }
286 
287     /**
288      * Process {@link HtmlSelect}.
289      * <p>
290      * Select and its Option children are rendered as native form-control widgets in real browsers,
291      * not as normal inline/block boxes. The widget's internal text drawing doesn't go through the
292      * same CSS text-layout pipeline that divs or spans use,
293      * so properties like white-space: pre on a &lt;select&gt; or &lt;option&gt; get ignored by Chrome/Firefox/etc.
294      * </p>
295      *
296      * @param builder the StringBuilder to add to
297      * @param htmlSelect the target to process
298      * @param mode the {@link Mode} to use for processing
299      * @param insideHead true if inside head section
300      * @param insideSelect true if inside a select
301      */
302     protected void appendSelect(final HtmlSerializerTextBuilder builder,
303             final HtmlSelect htmlSelect, final Mode mode, final boolean insideHead, final boolean insideSelect) {
304         if (browserVersion_.hasFeature(JS_INNER_TEXT_SELECT_EMPTY)) {
305             return;
306         }
307 
308         appendDescendantOptions(builder, htmlSelect, Mode.WHITE_SPACE_NORMAL, insideHead, true);
309     }
310 
311     /**
312      * Iterate over all descendant options.
313      *
314      * @param builder the StringBuilder to add to
315      * @param node the node to process
316      * @param mode the {@link Mode} to use for processing
317      * @param insideHead true if inside head section
318      * @param insideSelect true if inside a select
319      */
320     protected void appendDescendantOptions(final HtmlSerializerTextBuilder builder, final DomNode node,
321             final Mode mode, final boolean insideHead, final boolean insideSelect) {
322         for (final DomNode child : node.getChildNodes()) {
323             if (child instanceof HtmlOption) {
324                 builder.appendRequiredLineBreak();
325                 appendChildren(builder, child, Mode.WHITE_SPACE_NORMAL, insideHead, true);
326             }
327             else {
328                 appendDescendantOptions(builder, child, Mode.WHITE_SPACE_NORMAL, insideHead, true);
329             }
330         }
331     }
332 
333     private static Mode whiteSpaceStyle(final DomNode domNode, final Mode defaultMode) {
334         if (domNode instanceof DomElement) {
335             final SgmlPage page = domNode.getPage();
336             if (page != null) {
337                 if (page.getWebClient().getOptions().isCssEnabled()) {
338                     DomNode node = domNode;
339                     while (node != null) {
340                         if (node instanceof DomElement domElement) {
341                             final WebWindow window = page.getEnclosingWindow();
342                             if (window != null) {
343                                 final ComputedCssStyleDeclaration style =
344                                         window.getComputedStyle(domElement, null);
345                                 final String value = style.getStyleAttribute(Definition.WHITE_SPACE, false);
346 
347                                 if (!StringUtils.isEmptyOrNull(value)) {
348                                     if ("normal".equalsIgnoreCase(value)) {
349                                         return Mode.WHITE_SPACE_NORMAL;
350                                     }
351                                     if ("nowrap".equalsIgnoreCase(value)) {
352                                         return Mode.WHITE_SPACE_NORMAL;
353                                     }
354                                     if ("pre".equalsIgnoreCase(value)) {
355                                         return Mode.WHITE_SPACE_PRE;
356                                     }
357                                     if ("pre-wrap".equalsIgnoreCase(value)) {
358                                         return Mode.WHITE_SPACE_PRE;
359                                     }
360                                     if ("pre-line".equalsIgnoreCase(value)) {
361                                         return Mode.WHITE_SPACE_PRE_LINE;
362                                     }
363                                 }
364                             }
365                         }
366                         node = node.getParentNode();
367                     }
368                 }
369             }
370         }
371         return defaultMode;
372     }
373 
374     /**
375      * Helper to compose the text for the serializer based on several modes.
376      */
377     protected static class HtmlSerializerTextBuilder {
378 
379         /** Mode. */
380         protected enum Mode {
381             /**
382              * The mode for plain.
383              */
384             PLAIN,
385 
386             /**
387              * Sequences of white space are collapsed. Newline characters
388              * in the source are handled the same as other white space.
389              * Lines are broken as necessary to fill line boxes.
390              */
391             WHITE_SPACE_NORMAL,
392 
393             /**
394              * Sequences of white space are preserved. Lines are only broken
395              * at newline characters in the source and at <br> elements.
396              */
397             WHITE_SPACE_PRE,
398 
399             /**
400              * Sequences of white space are collapsed. Lines are broken
401              * at newline characters, at <br> and as necessary
402              * to fill line boxes.
403              */
404             WHITE_SPACE_PRE_LINE
405         }
406 
407         private enum State {
408             DEFAULT,
409             EMPTY,
410             BLANK_AT_END,
411             BLANK_AT_END_AFTER_NEWLINE,
412             NEWLINE_AT_END,
413             BREAK_AT_END,
414             BLOCK_SEPARATOR_AT_END,
415             REQUIRED_LINE_BREAK_AT_END
416         }
417 
418         private State state_;
419         private final StringBuilder builder_;
420         private int trimRightPos_;
421 
422         /**
423          * Ctor.
424          */
425         public HtmlSerializerTextBuilder() {
426             builder_ = new StringBuilder();
427             state_ = State.EMPTY;
428             trimRightPos_ = 0;
429         }
430 
431         /**
432          * Append a line separator.
433          */
434         public void appendRequiredLineBreak() {
435             if (state_ == State.EMPTY) {
436                 return;
437             }
438 
439             // trimRight
440             builder_.setLength(trimRightPos_);
441             if (trimRightPos_ == 0) {
442                 state_ = State.EMPTY;
443             }
444 
445             builder_.append('\n');
446             state_ = State.REQUIRED_LINE_BREAK_AT_END;
447         }
448 
449         /**
450          * Append the provided content.
451          * see https://drafts.csswg.org/css-text-3/#white-space
452          *
453          * @param content the content to add
454          * @param mode the {@link Mode}
455          */
456         public void append(final String content, final Mode mode) {
457             if (content == null) {
458                 return;
459             }
460             int length = content.length();
461             if (length == 0) {
462                 return;
463             }
464 
465             if (mode == Mode.PLAIN) {
466                 builder_.append(content);
467                 state_ = State.DEFAULT;
468                 trimRightPos_ = builder_.length();
469                 return;
470             }
471 
472             length--;
473             final int contentLength = content.length();
474             for (int i = 0; i < contentLength; i++) {
475                 char c = content.charAt(i);
476 
477                 // handle \r
478                 if (c == '\r') {
479                     if (length != i) {
480                         continue;
481                     }
482                     c = '\n';
483                 }
484 
485                 if (c == '\n') {
486                     if (mode == Mode.WHITE_SPACE_PRE) {
487                         switch (state_) {
488                             case EMPTY:
489                             case BLOCK_SEPARATOR_AT_END:
490                                 break;
491                             default:
492                                 builder_.append('\n');
493                                 state_ = State.NEWLINE_AT_END;
494                                 trimRightPos_ = builder_.length();
495                                 break;
496                         }
497                         continue;
498                     }
499 
500                     if (mode == Mode.WHITE_SPACE_PRE_LINE) {
501                         switch (state_) {
502                             case EMPTY:
503                             case BLOCK_SEPARATOR_AT_END:
504                                 break;
505                             case BLANK_AT_END:
506                                 builder_.setLength(trimRightPos_);
507                                 builder_.append('\n');
508                                 state_ = State.NEWLINE_AT_END;
509                                 trimRightPos_ = builder_.length();
510                                 break;
511                             default:
512                                 builder_.append('\n');
513                                 state_ = State.NEWLINE_AT_END;
514                                 trimRightPos_ = builder_.length();
515                                 break;
516                         }
517                         continue;
518                     }
519 
520                     switch (state_) {
521                         case EMPTY:
522                         case BLANK_AT_END:
523                         case BLANK_AT_END_AFTER_NEWLINE:
524                         case BLOCK_SEPARATOR_AT_END:
525                         case NEWLINE_AT_END:
526                         case BREAK_AT_END:
527                         case REQUIRED_LINE_BREAK_AT_END:
528                             break;
529                         default:
530                             builder_.append(' ');
531                             state_ = State.BLANK_AT_END;
532                             break;
533                     }
534                     continue;
535                 }
536 
537                 if (c == ' ' || c == '\t' || c == '\f') {
538                     if (mode == Mode.WHITE_SPACE_PRE) {
539                         if (c == '\t') {
540                             builder_.append('\t');
541                         }
542                         else {
543                             builder_.append(' ');
544                         }
545                         state_ = State.BLANK_AT_END;
546                         trimRightPos_ = builder_.length();
547 
548                         continue;
549                     }
550 
551                     if (mode == Mode.WHITE_SPACE_PRE_LINE) {
552                         switch (state_) {
553                             case EMPTY:
554                             case BLANK_AT_END:
555                             case BLANK_AT_END_AFTER_NEWLINE:
556                             case BREAK_AT_END:
557                             case NEWLINE_AT_END:
558                                 break;
559                             default:
560                                 builder_.append(' ');
561                                 state_ = State.BLANK_AT_END;
562                                 break;
563                         }
564                         continue;
565                     }
566 
567                     switch (state_) {
568                         case EMPTY:
569                         case BLANK_AT_END:
570                         case BLANK_AT_END_AFTER_NEWLINE:
571                         case BLOCK_SEPARATOR_AT_END:
572                         case NEWLINE_AT_END:
573                         case BREAK_AT_END:
574                         case REQUIRED_LINE_BREAK_AT_END:
575                             break;
576                         default:
577                             builder_.append(' ');
578                             state_ = State.BLANK_AT_END;
579                             break;
580                     }
581                     continue;
582                 }
583 
584                 builder_.append(c);
585                 state_ = State.DEFAULT;
586                 trimRightPos_ = builder_.length();
587             }
588         }
589 
590         /**
591          * Returns the constructed text.
592          *
593          * @return the constructed text.
594          */
595         public String getText() {
596             return builder_.substring(0, trimRightPos_);
597         }
598     }
599 }