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.css.CssStyleSheet.BLOCK;
18  
19  import java.util.List;
20  
21  import org.htmlunit.Page;
22  import org.htmlunit.SgmlPage;
23  import org.htmlunit.WebWindow;
24  import org.htmlunit.css.ComputedCssStyleDeclaration;
25  import org.htmlunit.css.StyleAttributes.Definition;
26  import org.htmlunit.html.DomCDataSection;
27  import org.htmlunit.html.DomComment;
28  import org.htmlunit.html.DomElement;
29  import org.htmlunit.html.DomNode;
30  import org.htmlunit.html.DomText;
31  import org.htmlunit.html.HtmlBody;
32  import org.htmlunit.html.HtmlBreak;
33  import org.htmlunit.html.HtmlCheckBoxInput;
34  import org.htmlunit.html.HtmlDetails;
35  import org.htmlunit.html.HtmlHiddenInput;
36  import org.htmlunit.html.HtmlInlineFrame;
37  import org.htmlunit.html.HtmlInput;
38  import org.htmlunit.html.HtmlMenu;
39  import org.htmlunit.html.HtmlNoFrames;
40  import org.htmlunit.html.HtmlNoScript;
41  import org.htmlunit.html.HtmlOption;
42  import org.htmlunit.html.HtmlOrderedList;
43  import org.htmlunit.html.HtmlPreformattedText;
44  import org.htmlunit.html.HtmlRadioButtonInput;
45  import org.htmlunit.html.HtmlResetInput;
46  import org.htmlunit.html.HtmlScript;
47  import org.htmlunit.html.HtmlSelect;
48  import org.htmlunit.html.HtmlStyle;
49  import org.htmlunit.html.HtmlSubmitInput;
50  import org.htmlunit.html.HtmlSummary;
51  import org.htmlunit.html.HtmlTable;
52  import org.htmlunit.html.HtmlTableCell;
53  import org.htmlunit.html.HtmlTableFooter;
54  import org.htmlunit.html.HtmlTableHeader;
55  import org.htmlunit.html.HtmlTableRow;
56  import org.htmlunit.html.HtmlTextArea;
57  import org.htmlunit.html.HtmlTitle;
58  import org.htmlunit.html.HtmlUnorderedList;
59  import org.htmlunit.html.TableRowGroup;
60  import org.htmlunit.html.serializer.HtmlSerializerVisibleText.HtmlSerializerTextBuilder.Mode;
61  import org.htmlunit.util.StringUtils;
62  
63  /**
64   * Special serializer to generate the output we need
65   * at least for selenium WebElement#getText().
66   * <p>This is also used from estimations by ComputedCSSStyleDeclaration.</p>
67   *
68   * @author Ronald Brill
69   * @author cd alexndr
70   */
71  public class HtmlSerializerVisibleText {
72  
73      /**
74       * Converts an HTML node to text.
75       * @param node a node
76       * @return the text representation according to the setting of this serializer
77       */
78      public String asText(final DomNode node) {
79          if (node instanceof HtmlBreak) {
80              return "";
81          }
82          final HtmlSerializerTextBuilder builder = new HtmlSerializerTextBuilder();
83          appendNode(builder, node, whiteSpaceStyle(node, Mode.WHITE_SPACE_NORMAL));
84          return builder.getText();
85      }
86  
87      /**
88       * Iterate over all Children and call appendNode() for every.
89       *
90       * @param builder the StringBuilder to add to
91       * @param node the node to process
92       * @param mode the {@link Mode} to use for processing
93       */
94      protected void appendChildren(final HtmlSerializerTextBuilder builder, final DomNode node, final Mode mode) {
95          for (final DomNode child : node.getChildren()) {
96              appendNode(builder, child, updateWhiteSpaceStyle(node, mode));
97          }
98      }
99  
100     /**
101      * The core distribution method call the different appendXXX
102      * methods depending on the type of the given node.
103      *
104      * @param builder the StringBuilder to add to
105      * @param node the node to process
106      * @param mode the {@link Mode} to use for processing
107      */
108     protected void appendNode(final HtmlSerializerTextBuilder builder, final DomNode node, final Mode mode) {
109         if (node instanceof DomCDataSection) {
110             // ignore
111         }
112         else if (node instanceof DomText text1) {
113             appendText(builder, text1, mode);
114         }
115         else if (node instanceof DomComment comment) {
116             appendComment(builder, comment, mode);
117         }
118         else if (node instanceof HtmlBreak break1) {
119             appendBreak(builder, break1, mode);
120         }
121         else if (node instanceof HtmlHiddenInput input4) {
122             appendHiddenInput(builder, input4, mode);
123         }
124         else if (node instanceof HtmlScript script1) {
125             appendScript(builder, script1, mode);
126         }
127         else if (node instanceof HtmlStyle style) {
128             appendStyle(builder, style, mode);
129         }
130         else if (node instanceof HtmlNoFrames frames) {
131             appendNoFrames(builder, frames, mode);
132         }
133         else if (node instanceof HtmlTextArea area) {
134             appendTextArea(builder, area, mode);
135         }
136         else if (node instanceof HtmlTitle title) {
137             appendTitle(builder, title, mode);
138         }
139         else if (node instanceof HtmlTableRow row) {
140             appendTableRow(builder, row, mode);
141         }
142         else if (node instanceof HtmlSelect select) {
143             appendSelect(builder, select, mode);
144         }
145         else if (node instanceof HtmlOption option) {
146             appendOption(builder, option, mode);
147         }
148         else if (node instanceof HtmlSubmitInput input3) {
149             appendSubmitInput(builder, input3, mode);
150         }
151         else if (node instanceof HtmlResetInput input2) {
152             appendResetInput(builder, input2, mode);
153         }
154         else if (node instanceof HtmlCheckBoxInput input1) {
155             appendCheckBoxInput(builder, input1, mode);
156         }
157         else if (node instanceof HtmlRadioButtonInput input) {
158             appendRadioButtonInput(builder, input, mode);
159         }
160         else if (node instanceof HtmlInput) {
161             // nothing
162         }
163         else if (node instanceof HtmlTable table) {
164             appendTable(builder, table, mode);
165         }
166         else if (node instanceof HtmlOrderedList list1) {
167             appendOrderedList(builder, list1, mode);
168         }
169         else if (node instanceof HtmlUnorderedList list) {
170             appendUnorderedList(builder, list, mode);
171         }
172         else if (node instanceof HtmlPreformattedText text) {
173             appendPreformattedText(builder, text, mode);
174         }
175         else if (node instanceof HtmlInlineFrame frame) {
176             appendInlineFrame(builder, frame, mode);
177         }
178         else if (node instanceof HtmlMenu menu) {
179             appendMenu(builder, menu, mode);
180         }
181         else if (node instanceof HtmlDetails details) {
182             appendDetails(builder, details, mode);
183         }
184         else if (node instanceof HtmlNoScript script && node.getPage().getWebClient().isJavaScriptEnabled()) {
185             appendNoScript(builder, script, mode);
186         }
187         else {
188             appendDomNode(builder, node, mode);
189         }
190     }
191 
192     /**
193      * Process {@link DomNode}.
194      *
195      * @param builder the StringBuilder to add to
196      * @param domNode the target to process
197      * @param mode the {@link Mode} to use for processing
198      */
199     protected void appendDomNode(final HtmlSerializerTextBuilder builder,
200             final DomNode domNode, final Mode mode) {
201         final boolean block;
202         if (domNode instanceof HtmlBody) {
203             block = false;
204         }
205         else if (domNode instanceof DomElement element) {
206             final WebWindow window = domNode.getPage().getEnclosingWindow();
207             final String display = window.getComputedStyle(element, null).getDisplay();
208             block = BLOCK.equals(display);
209         }
210         else {
211             block = false;
212         }
213 
214         if (block) {
215             builder.appendBlockSeparator();
216         }
217         appendChildren(builder, domNode, mode);
218         if (block) {
219             builder.appendBlockSeparator();
220         }
221     }
222 
223     /**
224      * Process {@link HtmlHiddenInput}.
225      *
226      * @param builder the StringBuilder to add to
227      * @param htmlHiddenInput the target to process
228      * @param mode the {@link Mode} to use for processing
229      */
230     protected void appendHiddenInput(final HtmlSerializerTextBuilder builder,
231             final HtmlHiddenInput htmlHiddenInput, final Mode mode) {
232         // nothing to do
233     }
234 
235     /**
236      * Process {@link HtmlScript}.
237      *
238      * @param builder the StringBuilder to add to
239      * @param htmlScript the target to process
240      * @param mode the {@link Mode} to use for processing
241      */
242     protected void appendScript(final HtmlSerializerTextBuilder builder,
243             final HtmlScript htmlScript, final Mode mode) {
244         // nothing to do
245     }
246 
247     /**
248      * Process {@link HtmlStyle}.
249      *
250      * @param builder the StringBuilder to add to
251      * @param htmlStyle the target to process
252      * @param mode the {@link Mode} to use for processing
253      */
254     protected void appendStyle(final HtmlSerializerTextBuilder builder,
255             final HtmlStyle htmlStyle, final Mode mode) {
256         // nothing to do
257     }
258 
259     /**
260      * Process {@link HtmlNoScript}.
261      *
262      * @param builder the StringBuilder to add to
263      * @param htmlNoScript the target to process
264      * @param mode the {@link Mode} to use for processing
265      */
266     protected void appendNoScript(final HtmlSerializerTextBuilder builder,
267             final HtmlNoScript htmlNoScript, final Mode mode) {
268         // nothing to do
269     }
270 
271     /**
272      * Process {@link HtmlNoFrames}.
273      *
274      * @param builder the StringBuilder to add to
275      * @param htmlNoFrames the target to process
276      * @param mode the {@link Mode} to use for processing
277      */
278     protected void appendNoFrames(final HtmlSerializerTextBuilder builder,
279             final HtmlNoFrames htmlNoFrames, final Mode mode) {
280         // nothing to do
281     }
282 
283     /**
284      * Process {@link HtmlSubmitInput}.
285      *
286      * @param builder the StringBuilder to add to
287      * @param htmlSubmitInput the target to process
288      * @param mode the {@link Mode} to use for processing
289      */
290     protected void appendSubmitInput(final HtmlSerializerTextBuilder builder,
291             final HtmlSubmitInput htmlSubmitInput, final Mode mode) {
292         // nothing to do
293     }
294 
295     /**
296      * Process {@link HtmlInput}.
297      *
298      * @param builder the StringBuilder to add to
299      * @param htmlInput the target to process
300      * @param mode the {@link Mode} to use for processing
301      */
302     protected void appendInput(final HtmlSerializerTextBuilder builder,
303             final HtmlInput htmlInput, final Mode mode) {
304         builder.append(htmlInput.getValueAttribute(), mode);
305     }
306 
307     /**
308      * Process {@link HtmlResetInput}.
309      *
310      * @param builder the StringBuilder to add to
311      * @param htmlResetInput the target to process
312      * @param mode the {@link Mode} to use for processing
313      */
314     protected void appendResetInput(final HtmlSerializerTextBuilder builder,
315             final HtmlResetInput htmlResetInput, final Mode mode) {
316         // nothing to do
317     }
318 
319     /**
320      * Process {@link HtmlMenu}.
321      * @param builder the StringBuilder to add to
322      * @param htmlMenu the target to process
323      * @param mode the {@link Mode} to use for processing
324      */
325     protected void appendMenu(final HtmlSerializerTextBuilder builder,
326                     final HtmlMenu htmlMenu, final Mode mode) {
327         builder.appendBlockSeparator();
328         boolean first = true;
329         for (final DomNode item : htmlMenu.getChildren()) {
330             if (!first) {
331                 builder.appendBlockSeparator();
332             }
333             first = false;
334             appendNode(builder, item, mode);
335         }
336         builder.appendBlockSeparator();
337     }
338 
339     /**
340      * Process {@link HtmlDetails}.
341      * @param builder the StringBuilder to add to
342      * @param htmlDetails the target to process
343      * @param mode the {@link Mode} to use for processing
344      */
345     protected void appendDetails(final HtmlSerializerTextBuilder builder,
346                     final HtmlDetails htmlDetails, final Mode mode) {
347         if (htmlDetails.isOpen()) {
348             appendChildren(builder, htmlDetails, mode);
349             return;
350         }
351 
352         for (final DomNode child : htmlDetails.getChildren()) {
353             if (child instanceof HtmlSummary) {
354                 appendNode(builder, child, mode);
355             }
356         }
357     }
358 
359     /**
360      * Process {@link HtmlTitle}.
361      * @param builder the StringBuilder to add to
362      * @param htmlTitle the target to process
363      * @param mode the {@link Mode} to use for processing
364      */
365     protected void appendTitle(final HtmlSerializerTextBuilder builder,
366             final HtmlTitle htmlTitle, final Mode mode) {
367         // nothing to do
368     }
369 
370     /**
371      * Process {@link HtmlTableRow}.
372      *
373      * @param builder the StringBuilder to add to
374      * @param htmlTableRow the target to process
375      * @param mode the {@link Mode} to use for processing
376      */
377     protected void appendTableRow(final HtmlSerializerTextBuilder builder,
378             final HtmlTableRow htmlTableRow, final Mode mode) {
379         boolean first = true;
380         for (final HtmlTableCell cell : htmlTableRow.getCells()) {
381             if (first) {
382                 first = false;
383             }
384             else {
385                 builder.appendBlank();
386             }
387             appendChildren(builder, cell, mode); // trim?
388         }
389     }
390 
391     /**
392      * Check domNode visibility.
393      * @param domNode the node to check
394      * @return true or false
395      */
396     protected boolean isDisplayed(final DomNode domNode) {
397         return domNode.isDisplayed();
398     }
399 
400     /**
401      * Process {@link HtmlTextArea}.
402      *
403      * @param builder the StringBuilder to add to
404      * @param htmlTextArea the target to process
405      * @param mode the {@link Mode} to use for processing
406      */
407     protected void appendTextArea(final HtmlSerializerTextBuilder builder,
408             final HtmlTextArea htmlTextArea, final Mode mode) {
409         if (isDisplayed(htmlTextArea)) {
410             builder.append(htmlTextArea.getDefaultValue(), whiteSpaceStyle(htmlTextArea, Mode.PRE));
411             builder.trimRight(Mode.PRE);
412         }
413     }
414 
415     /**
416      * Process {@link HtmlTable}.
417      *
418      * @param builder the StringBuilder to add to
419      * @param htmlTable the target to process
420      * @param mode the {@link Mode} to use for processing
421      */
422     protected void appendTable(final HtmlSerializerTextBuilder builder,
423             final HtmlTable htmlTable, final Mode mode) {
424         builder.appendBlockSeparator();
425         final String caption = htmlTable.getCaptionText();
426         if (caption != null) {
427             builder.append(caption, mode);
428             builder.appendBlockSeparator();
429         }
430 
431         boolean first = true;
432 
433         // first thead has to be displayed first and first tfoot has to be displayed last
434         final HtmlTableHeader tableHeader = htmlTable.getHeader();
435         if (tableHeader != null) {
436             first = appendTableRows(builder, mode, tableHeader.getRows(), true, null, null);
437         }
438         final HtmlTableFooter tableFooter = htmlTable.getFooter();
439 
440         final List<HtmlTableRow> tableRows = htmlTable.getRows();
441         first = appendTableRows(builder, mode, tableRows, first, tableHeader, tableFooter);
442 
443         if (tableFooter != null) {
444             first = appendTableRows(builder, mode, tableFooter.getRows(), first, null, null);
445         }
446         else if (tableRows.isEmpty()) {
447             final DomNode firstChild = htmlTable.getFirstChild();
448             if (firstChild != null) {
449                 appendNode(builder, firstChild, mode);
450             }
451         }
452 
453         builder.appendBlockSeparator();
454     }
455 
456     /**
457      * Process {@link HtmlTableRow}.
458      *
459      * @param builder the StringBuilder to add to
460      * @param mode the {@link Mode} to use for processing
461      * @param rows the rows
462      * @param first if true this is the first one
463      * @param skipParent1 skip row if the parent is this
464      * @param skipParent2 skip row if the parent is this
465      * @return true if this was the first one
466      */
467     protected boolean appendTableRows(final HtmlSerializerTextBuilder builder, final Mode mode,
468             final List<HtmlTableRow> rows, boolean first, final TableRowGroup skipParent1,
469             final TableRowGroup skipParent2) {
470         for (final HtmlTableRow row : rows) {
471             if (row.getParentNode() == skipParent1 || row.getParentNode() == skipParent2) {
472                 continue;
473             }
474             if (!first) {
475                 builder.appendBlockSeparator();
476             }
477             first = false;
478             appendTableRow(builder, row, mode);
479         }
480         return first;
481     }
482 
483     /**
484      * Process {@link HtmlSelect}.
485      *
486      * @param builder the StringBuilder to add to
487      * @param htmlSelect the target to process
488      * @param mode the {@link Mode} to use for processing
489      */
490     protected void appendSelect(final HtmlSerializerTextBuilder builder,
491             final HtmlSelect htmlSelect, final Mode mode) {
492         builder.appendBlockSeparator();
493         boolean leadingNlPending = false;
494         final Mode selectMode = whiteSpaceStyle(htmlSelect, mode);
495         for (final DomNode item : htmlSelect.getChildren()) {
496             if (leadingNlPending) {
497                 builder.appendBlockSeparator();
498                 leadingNlPending = false;
499             }
500 
501             builder.resetContentAdded();
502             appendNode(builder, item, whiteSpaceStyle(item, selectMode));
503             if (!leadingNlPending && builder.contentAdded_) {
504                 leadingNlPending = true;
505             }
506         }
507         builder.appendBlockSeparator();
508     }
509 
510     /**
511      * Process {@link HtmlSelect}.
512      *
513      * @param builder the StringBuilder to add to
514      * @param htmlOption the target to process
515      * @param mode the {@link Mode} to use for processing
516      */
517     protected void appendOption(final HtmlSerializerTextBuilder builder,
518             final HtmlOption htmlOption, final Mode mode) {
519         appendChildren(builder, htmlOption, mode);
520     }
521 
522     /**
523      * Process {@link HtmlOrderedList}.
524      *
525      * @param builder the StringBuilder to add to
526      * @param htmlOrderedList the OL element
527      * @param mode the {@link Mode} to use for processing
528      */
529     protected void appendOrderedList(final HtmlSerializerTextBuilder builder,
530             final HtmlOrderedList htmlOrderedList, final Mode mode) {
531         builder.appendBlockSeparator();
532         boolean leadingNlPending = false;
533         final Mode olMode = whiteSpaceStyle(htmlOrderedList, mode);
534         for (final DomNode item : htmlOrderedList.getChildren()) {
535             if (leadingNlPending) {
536                 builder.appendBlockSeparator();
537                 leadingNlPending = false;
538             }
539 
540             builder.resetContentAdded();
541             appendNode(builder, item, whiteSpaceStyle(item, olMode));
542             if (!leadingNlPending && builder.contentAdded_) {
543                 leadingNlPending = true;
544             }
545         }
546         builder.appendBlockSeparator();
547     }
548 
549     /**
550      * Process {@link HtmlUnorderedList}.
551      * @param builder the StringBuilder to add to
552      * @param htmlUnorderedList the target to process
553      * @param mode the {@link Mode} to use for processing
554      */
555     protected void appendUnorderedList(final HtmlSerializerTextBuilder builder,
556                     final HtmlUnorderedList htmlUnorderedList, final Mode mode) {
557         builder.appendBlockSeparator();
558         boolean leadingNlPending = false;
559         final Mode ulMode = whiteSpaceStyle(htmlUnorderedList, mode);
560         for (final DomNode item : htmlUnorderedList.getChildren()) {
561             if (leadingNlPending) {
562                 builder.appendBlockSeparator();
563                 leadingNlPending = false;
564             }
565 
566             builder.resetContentAdded();
567             appendNode(builder, item, whiteSpaceStyle(item, ulMode));
568             if (!leadingNlPending && builder.contentAdded_) {
569                 leadingNlPending = true;
570             }
571         }
572         builder.appendBlockSeparator();
573     }
574 
575     /**
576      * Process {@link HtmlPreformattedText}.
577      *
578      * @param builder the StringBuilder to add to
579      * @param htmlPreformattedText the target to process
580      * @param mode the {@link Mode} to use for processing
581      */
582     protected void appendPreformattedText(final HtmlSerializerTextBuilder builder,
583             final HtmlPreformattedText htmlPreformattedText, final Mode mode) {
584         if (isDisplayed(htmlPreformattedText)) {
585             builder.appendBlockSeparator();
586             appendChildren(builder, htmlPreformattedText, whiteSpaceStyle(htmlPreformattedText, Mode.PRE));
587             builder.appendBlockSeparator();
588         }
589     }
590 
591     /**
592      * Process {@link HtmlInlineFrame}.
593      *
594      * @param builder the StringBuilder to add to
595      * @param htmlInlineFrame the target to process
596      * @param mode the {@link Mode} to use for processing
597      */
598     protected void appendInlineFrame(final HtmlSerializerTextBuilder builder,
599             final HtmlInlineFrame htmlInlineFrame, final Mode mode) {
600         if (isDisplayed(htmlInlineFrame)) {
601             builder.appendBlockSeparator();
602             final Page page = htmlInlineFrame.getEnclosedPage();
603             if (page instanceof SgmlPage sgmlPage) {
604                 builder.append(sgmlPage.asNormalizedText(), mode);
605             }
606             builder.appendBlockSeparator();
607         }
608     }
609 
610     /**
611      * Process {@link DomText}.
612      *
613      * @param builder the StringBuilder to add to
614      * @param domText the target to process
615      * @param mode the {@link Mode} to use for processing
616      */
617     protected void appendText(final HtmlSerializerTextBuilder builder, final DomText domText, final Mode mode) {
618         final DomNode parent = domText.getParentNode();
619         if (parent instanceof HtmlTitle
620                 || parent instanceof HtmlScript) {
621             builder.append(domText.getData(), Mode.WHITE_SPACE_PRE_LINE);
622             return;
623         }
624 
625         if (parent == null
626                 || parent instanceof HtmlTitle
627                 || parent instanceof HtmlScript
628                 || isDisplayed(parent)) {
629             builder.append(domText.getData(), mode);
630         }
631     }
632 
633     /**
634      * Process {@link DomComment}.
635      *
636      * @param builder the StringBuilder to add to
637      * @param domComment the target to process
638      * @param mode the {@link Mode} to use for processing
639      */
640     protected void appendComment(final HtmlSerializerTextBuilder builder,
641             final DomComment domComment, final Mode mode) {
642         // nothing to do
643     }
644 
645     /**
646      * Process {@link HtmlBreak}.
647      *
648      * @param builder the StringBuilder to add to
649      * @param htmlBreak the target to process
650      * @param mode the {@link Mode} to use for processing
651      */
652     protected void appendBreak(final HtmlSerializerTextBuilder builder,
653             final HtmlBreak htmlBreak, final Mode mode) {
654         builder.appendBreak(mode);
655     }
656 
657     /**
658      * Process {@link HtmlCheckBoxInput}.
659      *
660      * @param builder the StringBuilder to add to
661      * @param htmlCheckBoxInput the target to process
662      * @param mode the {@link Mode} to use for processing
663      */
664     protected void appendCheckBoxInput(final HtmlSerializerTextBuilder builder,
665                     final HtmlCheckBoxInput htmlCheckBoxInput, final Mode mode) {
666         // nothing to do
667     }
668 
669     /**
670      * Process {@link HtmlRadioButtonInput}.
671      *
672      * @param builder the StringBuilder to add to
673      * @param htmlRadioButtonInput the target to process
674      * @param mode the {@link Mode} to use for processing
675      */
676     protected void appendRadioButtonInput(final HtmlSerializerTextBuilder builder,
677             final HtmlRadioButtonInput htmlRadioButtonInput, final Mode mode) {
678         // nothing to do
679     }
680 
681     protected Mode whiteSpaceStyle(final DomNode domNode, final Mode defaultMode) {
682         final Page page = domNode.getPage();
683         if (page != null) {
684             final WebWindow window = page.getEnclosingWindow();
685             if (window.getWebClient().getOptions().isCssEnabled()) {
686                 DomNode node = domNode;
687                 while (node != null) {
688                     if (node instanceof DomElement element) {
689                         final ComputedCssStyleDeclaration style = window.getComputedStyle(element, null);
690                         final String value = style.getStyleAttribute(Definition.WHITE_SPACE, false);
691                         if (!StringUtils.isEmptyOrNull(value)) {
692                             if ("normal".equalsIgnoreCase(value)) {
693                                 return Mode.WHITE_SPACE_NORMAL;
694                             }
695                             if ("nowrap".equalsIgnoreCase(value)) {
696                                 return Mode.WHITE_SPACE_NORMAL;
697                             }
698                             if ("pre".equalsIgnoreCase(value)) {
699                                 return Mode.WHITE_SPACE_PRE;
700                             }
701                             if ("pre-wrap".equalsIgnoreCase(value)) {
702                                 return Mode.WHITE_SPACE_PRE;
703                             }
704                             if ("pre-line".equalsIgnoreCase(value)) {
705                                 return Mode.WHITE_SPACE_PRE_LINE;
706                             }
707                         }
708                     }
709                     node = node.getParentNode();
710                 }
711             }
712         }
713         return defaultMode;
714     }
715 
716     protected Mode updateWhiteSpaceStyle(final DomNode domNode, final Mode defaultMode) {
717         final Page page = domNode.getPage();
718         if (page != null) {
719             final WebWindow window = page.getEnclosingWindow();
720             if (window.getWebClient().getOptions().isCssEnabled()) {
721                 if (domNode instanceof DomElement element) {
722                     final ComputedCssStyleDeclaration style = window.getComputedStyle(element, null);
723                     final String value = style.getStyleAttribute(Definition.WHITE_SPACE, false);
724                     if (!StringUtils.isEmptyOrNull(value)) {
725                         if ("normal".equalsIgnoreCase(value)) {
726                             return Mode.WHITE_SPACE_NORMAL;
727                         }
728                         if ("nowrap".equalsIgnoreCase(value)) {
729                             return Mode.WHITE_SPACE_NORMAL;
730                         }
731                         if ("pre".equalsIgnoreCase(value)) {
732                             return Mode.WHITE_SPACE_PRE;
733                         }
734                         if ("pre-wrap".equalsIgnoreCase(value)) {
735                             return Mode.WHITE_SPACE_PRE;
736                         }
737                         if ("pre-line".equalsIgnoreCase(value)) {
738                             return Mode.WHITE_SPACE_PRE_LINE;
739                         }
740                     }
741                 }
742             }
743         }
744         return defaultMode;
745     }
746 
747     /**
748      * Helper to compose the text for the serializer based on several modes.
749      */
750     protected static class HtmlSerializerTextBuilder {
751         /** Mode. */
752         protected enum Mode {
753             /**
754              * The mode for the pre tag.
755              */
756             PRE,
757 
758             /**
759              * Sequences of white space are collapsed. Newline characters
760              * in the source are handled the same as other white space.
761              * Lines are broken as necessary to fill line boxes.
762              */
763             WHITE_SPACE_NORMAL,
764 
765             /**
766              * Sequences of white space are preserved. Lines are only broken
767              * at newline characters in the source and at <br> elements.
768              */
769             WHITE_SPACE_PRE,
770 
771             /**
772              * Sequences of white space are collapsed. Lines are broken
773              * at newline characters, at <br> and as necessary
774              * to fill line boxes.
775              */
776             WHITE_SPACE_PRE_LINE
777         }
778 
779         private enum State {
780             DEFAULT,
781             EMPTY,
782             BLANK_AT_END,
783             BLANK_AT_END_AFTER_NEWLINE,
784             NEWLINE_AT_END,
785             BREAK_AT_END,
786             BLOCK_SEPARATOR_AT_END
787         }
788 
789         private State state_;
790         private final StringBuilder builder_;
791         private int trimRightPos_;
792         private boolean contentAdded_;
793 
794         /**
795          * Ctor.
796          */
797         public HtmlSerializerTextBuilder() {
798             builder_ = new StringBuilder();
799             state_ = State.EMPTY;
800             trimRightPos_ = 0;
801         }
802 
803         /**
804          * Append the provided content.
805          * see https://drafts.csswg.org/css-text-3/#white-space
806          *
807          * @param content the content to add
808          * @param mode the {@link Mode}
809          */
810         public void append(final String content, final Mode mode) {
811             if (content == null) {
812                 return;
813             }
814             int length = content.length();
815             if (length == 0) {
816                 return;
817             }
818 
819             length--;
820             final int contentLength = content.length();
821             for (int i = 0; i < contentLength; i++) {
822                 char c = content.charAt(i);
823 
824                 // handle \r
825                 if (c == '\r') {
826                     if (length != i) {
827                         continue;
828                     }
829                     c = '\n';
830                 }
831 
832                 if (c == '\n') {
833                     if (mode == Mode.WHITE_SPACE_PRE) {
834                         switch (state_) {
835                             case EMPTY:
836                             case BLOCK_SEPARATOR_AT_END:
837                                 break;
838                             default:
839                                 builder_.append('\n');
840                                 state_ = State.NEWLINE_AT_END;
841                                 trimRightPos_ = builder_.length();
842                                 break;
843                         }
844                         continue;
845                     }
846 
847                     if (mode == Mode.PRE) {
848                         builder_.append('\n');
849                         state_ = State.NEWLINE_AT_END;
850                         trimRightPos_ = builder_.length();
851 
852                         continue;
853                     }
854 
855                     if (mode == Mode.WHITE_SPACE_PRE_LINE) {
856                         switch (state_) {
857                             case EMPTY:
858                             case BLOCK_SEPARATOR_AT_END:
859                                 break;
860                             default:
861                                 builder_.append('\n');
862                                 state_ = State.NEWLINE_AT_END;
863                                 trimRightPos_ = builder_.length();
864                                 break;
865                         }
866                         continue;
867                     }
868 
869                     switch (state_) {
870                         case EMPTY:
871                         case BLANK_AT_END:
872                         case BLANK_AT_END_AFTER_NEWLINE:
873                         case BLOCK_SEPARATOR_AT_END:
874                         case NEWLINE_AT_END:
875                         case BREAK_AT_END:
876                             break;
877                         default:
878                             builder_.append(' ');
879                             state_ = State.BLANK_AT_END;
880                             break;
881                     }
882                     continue;
883                 }
884 
885                 if (c == ' ' || c == '\t' || c == '\f') {
886                     if (mode == Mode.WHITE_SPACE_PRE || mode == Mode.PRE) {
887                         appendBlank();
888                         continue;
889                     }
890 
891                     if (mode == Mode.WHITE_SPACE_PRE_LINE) {
892                         switch (state_) {
893                             case EMPTY:
894                             case BLANK_AT_END:
895                             case BLANK_AT_END_AFTER_NEWLINE:
896                             case BREAK_AT_END:
897                                 break;
898                             default:
899                                 builder_.append(' ');
900                                 state_ = State.BLANK_AT_END;
901                                 break;
902                         }
903                         continue;
904                     }
905 
906                     switch (state_) {
907                         case EMPTY:
908                         case BLANK_AT_END:
909                         case BLANK_AT_END_AFTER_NEWLINE:
910                         case BLOCK_SEPARATOR_AT_END:
911                         case NEWLINE_AT_END:
912                         case BREAK_AT_END:
913                             break;
914                         default:
915                             builder_.append(' ');
916                             state_ = State.BLANK_AT_END;
917                             break;
918                     }
919                     continue;
920                 }
921 
922                 if (c == (char) 160) {
923                     appendBlank();
924                     if (mode == Mode.WHITE_SPACE_NORMAL || mode == Mode.WHITE_SPACE_PRE_LINE) {
925                         state_ = State.DEFAULT;
926                     }
927                     continue;
928                 }
929                 builder_.append(c);
930                 state_ = State.DEFAULT;
931                 trimRightPos_ = builder_.length();
932                 contentAdded_ = true;
933             }
934         }
935 
936         /**
937          * Append a block separator.
938          */
939         public void appendBlockSeparator() {
940             switch (state_) {
941                 case EMPTY:
942                     break;
943                 case BLANK_AT_END:
944                     builder_.setLength(trimRightPos_);
945                     if (builder_.length() == 0) {
946                         state_ = State.EMPTY;
947                     }
948                     else {
949                         builder_.append('\n');
950                         state_ = State.BLOCK_SEPARATOR_AT_END;
951                     }
952                     break;
953                 case BLANK_AT_END_AFTER_NEWLINE:
954                     builder_.setLength(trimRightPos_ - 1);
955                     if (builder_.length() == 0) {
956                         state_ = State.EMPTY;
957                     }
958                     else {
959                         builder_.append('\n');
960                         state_ = State.BLOCK_SEPARATOR_AT_END;
961                     }
962                     break;
963                 case BLOCK_SEPARATOR_AT_END:
964                     break;
965                 case NEWLINE_AT_END:
966                 case BREAK_AT_END:
967                     builder_.setLength(builder_.length() - 1);
968                     trimRightPos_ = trimRightPos_ - 1;
969                     if (builder_.length() == 0) {
970                         state_ = State.EMPTY;
971                     }
972                     else {
973                         builder_.append('\n');
974                         state_ = State.BLOCK_SEPARATOR_AT_END;
975                     }
976                     break;
977                 default:
978                     builder_.append('\n');
979                     state_ = State.BLOCK_SEPARATOR_AT_END;
980                     break;
981             }
982         }
983 
984         /**
985          * Append a break.
986          *
987          * @param mode the {@link Mode}
988          */
989         public void appendBreak(final Mode mode) {
990             builder_.setLength(trimRightPos_);
991 
992             builder_.append('\n');
993             state_ = State.BREAK_AT_END;
994             trimRightPos_ = builder_.length();
995         }
996 
997         /**
998          * Append a blank.
999          */
1000         public void appendBlank() {
1001             builder_.append(' ');
1002             state_ = State.BLANK_AT_END;
1003             trimRightPos_ = builder_.length();
1004         }
1005 
1006         /**
1007          * Remove all trailing whitespace from the end.
1008          *
1009          * @param mode the {@link Mode}
1010          */
1011         public void trimRight(final Mode mode) {
1012             if (mode == Mode.PRE) {
1013                 switch (state_) {
1014                     case BLOCK_SEPARATOR_AT_END:
1015                     case NEWLINE_AT_END:
1016                     case BREAK_AT_END:
1017                         if (trimRightPos_ == builder_.length()) {
1018                             trimRightPos_--;
1019                         }
1020                         break;
1021                     default:
1022                         break;
1023                 }
1024             }
1025 
1026             builder_.setLength(trimRightPos_);
1027             state_ = State.DEFAULT;
1028             if (builder_.length() == 0) {
1029                 state_ = State.EMPTY;
1030             }
1031         }
1032 
1033         /**
1034          * Returns true if some content was already added.
1035          *
1036          * @return true if some content was already added
1037          */
1038         public boolean wasContentAdded() {
1039             return contentAdded_;
1040         }
1041 
1042         /**
1043          * Resets the contentAdded state to false.
1044          */
1045         public void resetContentAdded() {
1046             contentAdded_ = false;
1047         }
1048 
1049         /**
1050          * Returns the constructed text.
1051          *
1052          * @return the constructed text.
1053          */
1054         public String getText() {
1055             return builder_.substring(0, trimRightPos_);
1056         }
1057     }
1058 }