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_SVG_NL;
18  
19  import java.util.ArrayList;
20  import java.util.IdentityHashMap;
21  import java.util.List;
22  import java.util.Map;
23  import java.util.Set;
24  
25  import org.htmlunit.Page;
26  import org.htmlunit.WebWindow;
27  import org.htmlunit.css.ComputedCssStyleDeclaration;
28  import org.htmlunit.css.StyleAttributes.Definition;
29  import org.htmlunit.html.DomCDataSection;
30  import org.htmlunit.html.DomComment;
31  import org.htmlunit.html.DomElement;
32  import org.htmlunit.html.DomNode;
33  import org.htmlunit.html.DomText;
34  import org.htmlunit.html.HtmlBody;
35  import org.htmlunit.html.HtmlBreak;
36  import org.htmlunit.html.HtmlCheckBoxInput;
37  import org.htmlunit.html.HtmlDetails;
38  import org.htmlunit.html.HtmlElement;
39  import org.htmlunit.html.HtmlExample;
40  import org.htmlunit.html.HtmlHiddenInput;
41  import org.htmlunit.html.HtmlInlineFrame;
42  import org.htmlunit.html.HtmlInput;
43  import org.htmlunit.html.HtmlListing;
44  import org.htmlunit.html.HtmlMenu;
45  import org.htmlunit.html.HtmlNoFrames;
46  import org.htmlunit.html.HtmlNoScript;
47  import org.htmlunit.html.HtmlOption;
48  import org.htmlunit.html.HtmlOrderedList;
49  import org.htmlunit.html.HtmlPlainText;
50  import org.htmlunit.html.HtmlPreformattedText;
51  import org.htmlunit.html.HtmlRadioButtonInput;
52  import org.htmlunit.html.HtmlResetInput;
53  import org.htmlunit.html.HtmlScript;
54  import org.htmlunit.html.HtmlSelect;
55  import org.htmlunit.html.HtmlStyle;
56  import org.htmlunit.html.HtmlSubmitInput;
57  import org.htmlunit.html.HtmlSummary;
58  import org.htmlunit.html.HtmlSvg;
59  import org.htmlunit.html.HtmlTable;
60  import org.htmlunit.html.HtmlTableBody;
61  import org.htmlunit.html.HtmlTableCell;
62  import org.htmlunit.html.HtmlTableFooter;
63  import org.htmlunit.html.HtmlTableHeader;
64  import org.htmlunit.html.HtmlTableRow;
65  import org.htmlunit.html.HtmlTextArea;
66  import org.htmlunit.html.HtmlTitle;
67  import org.htmlunit.html.HtmlUnorderedList;
68  import org.htmlunit.html.TableRowGroup;
69  import org.htmlunit.html.serializer.HtmlSerializerVisibleText.HtmlSerializerTextBuilder.Mode;
70  import org.htmlunit.svg.SvgDesc;
71  import org.htmlunit.svg.SvgTitle;
72  import org.htmlunit.util.StringUtils;
73  
74  /**
75   * Special serializer to generate the output we need
76   * at least for selenium WebElement#getText().
77   * <p>This is also used for estimations by ComputedCssStyleDeclaration.</p>
78   *
79   * @author Ronald Brill
80   * @author cd alexndr
81   */
82  public class HtmlSerializerVisibleText {
83  
84      /** Rough estimation of the width of a char used to detect offscreen elements. */
85      private static final int ESTIMATED_CHAR_WIDTH = 16;
86  
87      /** Rough estimation of the height of a line used to detect offscreen elements. */
88      private static final int ESTIMATED_LINE_HEIGHT = 20;
89  
90      /**
91       * The displays handled as block by WebDriver (everything that is not one of the
92       * inline display boxes 'inline', 'inline-block', 'inline-table', 'none', 'table-cell', ...).
93       */
94      private static final Set<String> BLOCK_DISPLAYS = Set.of(
95              "block", "list-item", "flex", "grid", "flow-root", "contents",
96              "table", "table-row", "table-caption",
97              "table-row-group", "table-header-group", "table-footer-group",
98              "ruby", "ruby-base", "ruby-text", "ruby-text-container");
99  
100     /*
101      * Caches for the current run; the dom and the styles are not changed while serializing
102      * and many checks have to walk up the ancestors for every node.
103      */
104 
105     /** The result of {@link #isDisplayed(DomNode)}. */
106     private final Map<DomNode, Boolean> displayedCache_ = new IdentityHashMap<>();
107     /** The result of {@link #isTransparent(DomNode)}. */
108     private final Map<DomNode, Boolean> transparentCache_ = new IdentityHashMap<>();
109     /** The white-space mode defined by the node or the ancestors ({@code null} if not defined). */
110     private final Map<DomNode, Mode> whiteSpaceCache_ = new IdentityHashMap<>();
111 
112     /** Whether the WebDriver specific visibility checks (opacity, content-visibility, offscreen) are done. */
113     private final boolean webDriverVisibility_;
114 
115     /**
116      * Ctor; creates a serializer for the WebDriver visible text.
117      */
118     public HtmlSerializerVisibleText() {
119         this(true);
120     }
121 
122     /**
123      * Ctor.
124      * <p>The layout (e.g. the estimation of the size of an element) has to use the text
125      * without the WebDriver specific visibility checks; the browsers do the layout
126      * also for elements that are transparent or positioned outside of the page.</p>
127      *
128      * @param webDriverVisibility whether the WebDriver specific visibility checks
129      *        (opacity, content-visibility, offscreen) are done
130      */
131     public HtmlSerializerVisibleText(final boolean webDriverVisibility) {
132         webDriverVisibility_ = webDriverVisibility;
133     }
134 
135     /**
136      * Converts an HTML node to text.
137      * @param node a node
138      * @return the text representation according to the setting of this serializer
139      */
140     public String asText(final DomNode node) {
141         if (node instanceof HtmlBreak) {
142             return "";
143         }
144 
145         displayedCache_.clear();
146         transparentCache_.clear();
147         whiteSpaceCache_.clear();
148 
149         final HtmlSerializerTextBuilder builder = new HtmlSerializerTextBuilder();
150         appendNode(builder, node, whiteSpaceStyle(node, Mode.WHITE_SPACE_NORMAL));
151         return builder.getText();
152     }
153 
154     /**
155      * Iterates over all children and calls appendNode() for every child.
156      *
157      * @param builder the StringBuilder to add to
158      * @param node the node to process
159      * @param mode the {@link Mode} to use for processing
160      */
161     protected void appendChildren(final HtmlSerializerTextBuilder builder, final DomNode node, final Mode mode) {
162         final Mode childMode = updateWhiteSpaceStyle(node, mode);
163         for (final DomNode child : node.getChildren()) {
164             appendNode(builder, child, childMode);
165         }
166     }
167 
168     /**
169      * The core distribution method; calls the different appendXXX
170      * methods depending on the type of the given node.
171      *
172      * @param builder the StringBuilder to add to
173      * @param node the node to process
174      * @param mode the {@link Mode} to use for processing
175      */
176     protected void appendNode(final HtmlSerializerTextBuilder builder, final DomNode node, final Mode mode) {
177         if (node instanceof DomCDataSection) {
178             // ignore
179         }
180         else if (node instanceof DomText text1) {
181             appendText(builder, text1, mode);
182         }
183         else if (node instanceof DomComment comment) {
184             appendComment(builder, comment, mode);
185         }
186         else if (node instanceof HtmlBreak break1) {
187             appendBreak(builder, break1, mode);
188         }
189         else if (node instanceof HtmlHiddenInput input4) {
190             appendHiddenInput(builder, input4, mode);
191         }
192         else if (node instanceof HtmlScript script1) {
193             appendScript(builder, script1, mode);
194         }
195         else if (node instanceof HtmlStyle style) {
196             appendStyle(builder, style, mode);
197         }
198         else if (node instanceof HtmlNoFrames frames) {
199             appendNoFrames(builder, frames, mode);
200         }
201         else if (node instanceof HtmlTextArea area) {
202             appendTextArea(builder, area, mode);
203         }
204         else if (node instanceof HtmlTitle title) {
205             appendTitle(builder, title, mode);
206         }
207         else if (node instanceof HtmlTableRow row) {
208             appendTableRow(builder, row, mode);
209         }
210         else if (node instanceof HtmlSelect select) {
211             appendSelect(builder, select, mode);
212         }
213         else if (node instanceof HtmlOption option) {
214             appendOption(builder, option, mode);
215         }
216         else if (node instanceof HtmlSubmitInput input3) {
217             appendSubmitInput(builder, input3, mode);
218         }
219         else if (node instanceof HtmlResetInput input2) {
220             appendResetInput(builder, input2, mode);
221         }
222         else if (node instanceof HtmlCheckBoxInput input1) {
223             appendCheckBoxInput(builder, input1, mode);
224         }
225         else if (node instanceof HtmlRadioButtonInput input) {
226             appendRadioButtonInput(builder, input, mode);
227         }
228         else if (node instanceof HtmlInput) {
229             // nothing
230         }
231         else if (node instanceof HtmlTable table) {
232             appendTable(builder, table, mode);
233         }
234         else if (node instanceof HtmlOrderedList list1) {
235             appendOrderedList(builder, list1, mode);
236         }
237         else if (node instanceof HtmlUnorderedList list) {
238             appendUnorderedList(builder, list, mode);
239         }
240         else if (node instanceof HtmlPreformattedText
241                 || node instanceof HtmlListing
242                 || node instanceof HtmlExample
243                 || node instanceof HtmlPlainText) {
244             appendPreformattedText(builder, (HtmlElement) node, mode);
245         }
246         else if (node instanceof HtmlInlineFrame frame) {
247             appendInlineFrame(builder, frame, mode);
248         }
249         else if (node instanceof HtmlMenu menu) {
250             appendMenu(builder, menu, mode);
251         }
252         else if (node instanceof HtmlDetails details) {
253             appendDetails(builder, details, mode);
254         }
255         else if (node instanceof HtmlSvg svg) {
256             appendSvg(builder, svg, mode);
257         }
258         else if (node instanceof HtmlNoScript script && node.getPage().getWebClient().isJavaScriptEnabled()) {
259             appendNoScript(builder, script, mode);
260         }
261         else {
262             appendDomNode(builder, node, mode);
263         }
264     }
265 
266     /**
267      * Process {@link DomNode}.
268      *
269      * @param builder the StringBuilder to add to
270      * @param domNode the target to process
271      * @param mode the {@link Mode} to use for processing
272      */
273     protected void appendDomNode(final HtmlSerializerTextBuilder builder,
274             final DomNode domNode, final Mode mode) {
275         final boolean block;
276         if (domNode instanceof HtmlBody) {
277             block = false;
278         }
279         else if (domNode instanceof DomElement element) {
280             block = isBlockLevel(element);
281         }
282         else {
283             block = false;
284         }
285 
286         if (block) {
287             builder.appendBlockSeparator();
288         }
289         appendChildren(builder, domNode, mode);
290         if (block) {
291             builder.appendBlockSeparator();
292         }
293         else if (isCssTableCell(domNode) && isCssTableCell(domNode.getNextElementSibling())) {
294             builder.appendCellSeparator();
295         }
296     }
297 
298     /**
299      * Checks if the given node is an element with display 'table-cell'.
300      *
301      * @param node the node to check
302      * @return true if the node is an element with display 'table-cell'
303      */
304     private static boolean isCssTableCell(final DomNode node) {
305         if (node instanceof DomElement element && !(node instanceof HtmlTableCell)) {
306             final WebWindow window = node.getPage().getEnclosingWindow();
307             return "table-cell".equals(window.getComputedStyle(element, null).getDisplay());
308         }
309         return false;
310     }
311 
312     /**
313      * Process {@link HtmlHiddenInput}.
314      *
315      * @param builder the StringBuilder to add to
316      * @param htmlHiddenInput the target to process
317      * @param mode the {@link Mode} to use for processing
318      */
319     protected void appendHiddenInput(final HtmlSerializerTextBuilder builder,
320             final HtmlHiddenInput htmlHiddenInput, final Mode mode) {
321         // nothing to do
322     }
323 
324     /**
325      * Process {@link HtmlScript}.
326      *
327      * @param builder the StringBuilder to add to
328      * @param htmlScript the target to process
329      * @param mode the {@link Mode} to use for processing
330      */
331     protected void appendScript(final HtmlSerializerTextBuilder builder,
332             final HtmlScript htmlScript, final Mode mode) {
333         // nothing to do
334     }
335 
336     /**
337      * Process {@link HtmlStyle}.
338      *
339      * @param builder the StringBuilder to add to
340      * @param htmlStyle the target to process
341      * @param mode the {@link Mode} to use for processing
342      */
343     protected void appendStyle(final HtmlSerializerTextBuilder builder,
344             final HtmlStyle htmlStyle, final Mode mode) {
345         // nothing to do
346     }
347 
348     /**
349      * Process {@link HtmlNoScript}.
350      *
351      * @param builder the StringBuilder to add to
352      * @param htmlNoScript the target to process
353      * @param mode the {@link Mode} to use for processing
354      */
355     protected void appendNoScript(final HtmlSerializerTextBuilder builder,
356             final HtmlNoScript htmlNoScript, final Mode mode) {
357         // nothing to do
358     }
359 
360     /**
361      * Process {@link HtmlNoFrames}.
362      *
363      * @param builder the StringBuilder to add to
364      * @param htmlNoFrames the target to process
365      * @param mode the {@link Mode} to use for processing
366      */
367     protected void appendNoFrames(final HtmlSerializerTextBuilder builder,
368             final HtmlNoFrames htmlNoFrames, final Mode mode) {
369         // nothing to do
370     }
371 
372     /**
373      * Process {@link HtmlSubmitInput}.
374      *
375      * @param builder the StringBuilder to add to
376      * @param htmlSubmitInput the target to process
377      * @param mode the {@link Mode} to use for processing
378      */
379     protected void appendSubmitInput(final HtmlSerializerTextBuilder builder,
380             final HtmlSubmitInput htmlSubmitInput, final Mode mode) {
381         // nothing to do
382     }
383 
384     /**
385      * Process {@link HtmlInput}.
386      *
387      * @param builder the StringBuilder to add to
388      * @param htmlInput the target to process
389      * @param mode the {@link Mode} to use for processing
390      */
391     protected void appendInput(final HtmlSerializerTextBuilder builder,
392             final HtmlInput htmlInput, final Mode mode) {
393         builder.append(htmlInput.getValueAttribute(), mode);
394     }
395 
396     /**
397      * Process {@link HtmlResetInput}.
398      *
399      * @param builder the StringBuilder to add to
400      * @param htmlResetInput the target to process
401      * @param mode the {@link Mode} to use for processing
402      */
403     protected void appendResetInput(final HtmlSerializerTextBuilder builder,
404             final HtmlResetInput htmlResetInput, final Mode mode) {
405         // nothing to do
406     }
407 
408     /**
409      * Checks if the given node is block level; this includes elements that are
410      * blockified (children of flex and grid containers).
411      *
412      * @param node the node to check
413      * @return true if the node is a block level element
414      */
415     private static boolean isBlockLevel(final DomNode node) {
416         if (node instanceof DomElement element) {
417             final WebWindow window = node.getPage().getEnclosingWindow();
418             final ComputedCssStyleDeclaration style = window.getComputedStyle(element, null);
419             final String display = style.getDisplay();
420             if (BLOCK_DISPLAYS.contains(display)) {
421                 return true;
422             }
423 
424             // absolute positioned and floating boxes are blockified
425             final String position = style.getPosition();
426             if ("absolute".equals(position) || "fixed".equals(position)) {
427                 return true;
428             }
429             final String floatValue = style.getStyleAttribute(Definition.FLOAT, false);
430             if ("left".equals(floatValue) || "right".equals(floatValue)) {
431                 return true;
432             }
433 
434             // children of flex and grid containers are blockified
435             if (element.getParentNode() instanceof DomElement parent) {
436                 final String parentDisplay = window.getComputedStyle(parent, null).getDisplay();
437                 return "flex".equals(parentDisplay) || "inline-flex".equals(parentDisplay)
438                         || "grid".equals(parentDisplay) || "inline-grid".equals(parentDisplay);
439             }
440         }
441         return false;
442     }
443 
444     /**
445      * Process {@link HtmlMenu}; every child is placed on a line of its own.
446      *
447      * @param builder the StringBuilder to add to
448      * @param htmlMenu the target to process
449      * @param mode the {@link Mode} to use for processing
450      */
451     protected void appendMenu(final HtmlSerializerTextBuilder builder,
452                     final HtmlMenu htmlMenu, final Mode mode) {
453         builder.appendBlockSeparator();
454         boolean first = true;
455         for (final DomNode item : htmlMenu.getChildren()) {
456             if (!first) {
457                 builder.appendBlockSeparator();
458             }
459             first = false;
460             appendNode(builder, item, mode);
461         }
462         builder.appendBlockSeparator();
463     }
464 
465     /**
466      * Process {@link HtmlDetails}.
467      *
468      * @param builder the StringBuilder to add to
469      * @param htmlDetails the target to process
470      * @param mode the {@link Mode} to use for processing
471      */
472     protected void appendDetails(final HtmlSerializerTextBuilder builder,
473                     final HtmlDetails htmlDetails, final Mode mode) {
474         if (htmlDetails.isOpen()) {
475             appendChildren(builder, htmlDetails, mode);
476             return;
477         }
478 
479         // like WebDriver: element children of a closed details are hidden (except the summary),
480         // text children are still visible
481         for (final DomNode child : htmlDetails.getChildren()) {
482             if (child instanceof HtmlSummary || child instanceof DomText) {
483                 appendNode(builder, child, mode);
484             }
485         }
486     }
487 
488     /**
489      * Process {@link HtmlTitle}.
490      *
491      * @param builder the StringBuilder to add to
492      * @param htmlTitle the target to process
493      * @param mode the {@link Mode} to use for processing
494      */
495     protected void appendTitle(final HtmlSerializerTextBuilder builder,
496             final HtmlTitle htmlTitle, final Mode mode) {
497         // nothing to do
498     }
499 
500     /**
501      * Process {@link HtmlTableRow}.
502      *
503      * @param builder the StringBuilder to add to
504      * @param htmlTableRow the target to process
505      * @param mode the {@link Mode} to use for processing
506      */
507     protected void appendTableRow(final HtmlSerializerTextBuilder builder,
508             final HtmlTableRow htmlTableRow, final Mode mode) {
509         boolean first = true;
510         for (final HtmlTableCell cell : htmlTableRow.getCells()) {
511             if (first) {
512                 first = false;
513             }
514             else {
515                 builder.appendCellSeparator();
516             }
517             appendChildren(builder, cell, mode);
518         }
519     }
520 
521     /**
522      * Checks if the given node is visible for WebDriver; this includes the
523      * checks done by {@link DomNode#isDisplayed()} and the WebDriver specific checks
524      * (opacity, content-visibility, offscreen).
525      * The result is cached for the current run.
526      * <p>WebDriver (and the browsers) also hide the content of an element with a size of zero
527      * and hidden overflow. HtmlUnit does not check this because the size of many elements
528      * is calculated by javascript (e.g. GWT layout panels) based on the estimated sizes
529      * of other elements; a wrong estimation would hide content that is visible in real browsers.</p>
530      *
531      * @param domNode the node to check
532      * @return true if the node is visible
533      */
534     protected boolean isDisplayed(final DomNode domNode) {
535         return displayedCache_.computeIfAbsent(domNode, node -> node.isDisplayed() && !isTransparent(node));
536     }
537 
538     /**
539      * Checks if the element is positioned outside of the page (e.g. 'left: -9999px').
540      * <p>The calculated width/height can't be used here because the calculation
541      * itself depends on the visible text. Therefore we use the explicit width/height
542      * or a rough estimation based on the text length.</p>
543      *
544      * @param element the element
545      * @param style the computed style of the element
546      * @return true if the element is not visible
547      */
548     private static boolean isOffscreen(final DomElement element, final ComputedCssStyleDeclaration style) {
549         final String position = style.getPosition();
550         if (!"absolute".equals(position) && !"fixed".equals(position)) {
551             return false;
552         }
553 
554         final int left = pixelValue(style.getStyleAttribute(Definition.LEFT, false));
555         if (left < 0) {
556             int width = pixelValue(style.getStyleAttribute(Definition.WIDTH, false));
557             if (width <= 0) {
558                 width = element.getTextContent().length() * ESTIMATED_CHAR_WIDTH;
559             }
560             if (left + width <= 0) {
561                 return true;
562             }
563         }
564 
565         final int top = pixelValue(style.getStyleAttribute(Definition.TOP, false));
566         if (top < 0) {
567             int height = pixelValue(style.getStyleAttribute(Definition.HEIGHT, false));
568             if (height <= 0) {
569                 height = ESTIMATED_LINE_HEIGHT;
570             }
571             return top + height <= 0;
572         }
573         return false;
574     }
575 
576     /**
577      * Returns the integer value of a pixel length; 0 for all other values.
578      *
579      * @param length the length
580      * @return the pixel value
581      */
582     private static int pixelValue(final String length) {
583         if (StringUtils.isEmptyOrNull(length) || !length.endsWith("px")) {
584             return 0;
585         }
586         try {
587             return (int) Double.parseDouble(length.substring(0, length.length() - 2).trim());
588         }
589         catch (final NumberFormatException e) {
590             return 0;
591         }
592     }
593 
594     /**
595      * Checks if the given node or one of its ancestors is not visible for WebDriver
596      * (opacity of zero, content-visibility hidden or offscreen).
597      * The results are cached for the current run; the ancestors share the cached result.
598      *
599      * @param domNode the node to check
600      * @return true if the node is not visible
601      */
602     private boolean isTransparent(final DomNode domNode) {
603         if (!webDriverVisibility_) {
604             return false;
605         }
606 
607         final Page page = domNode.getPage();
608         if (page == null) {
609             return false;
610         }
611         final WebWindow window = page.getEnclosingWindow();
612         if (!window.getWebClient().getOptions().isCssEnabled()) {
613             return false;
614         }
615 
616         // walk up until the first transparent element or the first known result;
617         // all the nodes on the way share this result
618         final List<DomNode> path = new ArrayList<>();
619         boolean transparent = false;
620         DomNode node = domNode;
621         while (node != null) {
622             final Boolean cached = transparentCache_.get(node);
623             if (cached != null) {
624                 transparent = cached;
625                 break;
626             }
627             path.add(node);
628             if (node instanceof DomElement element
629                     && isTransparentElement(element, window.getComputedStyle(element, null))) {
630                 transparent = true;
631                 break;
632             }
633             node = node.getParentNode();
634         }
635 
636         for (final DomNode pathNode : path) {
637             transparentCache_.put(pathNode, transparent);
638         }
639         return transparent;
640     }
641 
642     /**
643      * Checks if the given element itself (the ancestors are not checked) is not visible
644      * for WebDriver (opacity of zero, content-visibility hidden or offscreen).
645      *
646      * @param element the element to check
647      * @param style the computed style of the element
648      * @return true if the element is not visible
649      */
650     private static boolean isTransparentElement(final DomElement element, final ComputedCssStyleDeclaration style) {
651         // WebDriver also hides the content of elements with a size of zero and hidden overflow;
652         // this is not done here because the size of many elements is calculated by
653         // javascript (e.g. GWT layout panels) based on the (estimated) sizes of other elements
654         if ("hidden".equals(style.getStyleAttribute("content-visibility"))
655                 || isOffscreen(element, style)) {
656             return true;
657         }
658 
659         final String opacity = style.getStyleAttribute(Definition.OPACITY, false);
660         if (!StringUtils.isEmptyOrNull(opacity)) {
661             try {
662                 return Double.parseDouble(opacity.trim()) <= 0;
663             }
664             catch (final NumberFormatException e) {
665                 // ignore
666             }
667         }
668         return false;
669     }
670 
671     /**
672      * Process {@link HtmlTextArea}.
673      *
674      * @param builder the StringBuilder to add to
675      * @param htmlTextArea the target to process
676      * @param mode the {@link Mode} to use for processing
677      */
678     protected void appendTextArea(final HtmlSerializerTextBuilder builder,
679             final HtmlTextArea htmlTextArea, final Mode mode) {
680         if (isDisplayed(htmlTextArea)) {
681             builder.append(htmlTextArea.getDefaultValue(), whiteSpaceStyle(htmlTextArea, Mode.PRE));
682             builder.trimRight(Mode.PRE);
683         }
684     }
685 
686     /**
687      * Process {@link HtmlTable}.
688      *
689      * @param builder the StringBuilder to add to
690      * @param htmlTable the target to process
691      * @param mode the {@link Mode} to use for processing
692      */
693     protected void appendTable(final HtmlSerializerTextBuilder builder,
694             final HtmlTable htmlTable, final Mode mode) {
695         builder.appendBlockSeparator();
696         final String caption = htmlTable.getCaptionText();
697         if (caption != null) {
698             builder.append(caption, mode);
699             builder.appendBlockSeparator();
700         }
701 
702         boolean first = true;
703 
704         // first thead has to be displayed first and first tfoot has to be displayed last
705         final HtmlTableHeader tableHeader = htmlTable.getHeader();
706         if (tableHeader != null) {
707             first = appendTableRows(builder, mode, tableHeader.getRows(), true, null, null);
708         }
709         final HtmlTableFooter tableFooter = htmlTable.getFooter();
710 
711         // like WebDriver we use the document order if the footer is before the body
712         final boolean footerFirst = tableFooter != null && isFooterBeforeBody(htmlTable);
713         if (footerFirst) {
714             first = appendTableRows(builder, mode, tableFooter.getRows(), first, null, null);
715         }
716 
717         final List<HtmlTableRow> tableRows = htmlTable.getRows();
718         first = appendTableRows(builder, mode, tableRows, first, tableHeader, tableFooter);
719 
720         if (tableFooter != null && !footerFirst) {
721             first = appendTableRows(builder, mode, tableFooter.getRows(), first, null, null);
722         }
723         else if (tableRows.isEmpty()) {
724             final DomNode firstChild = htmlTable.getFirstChild();
725             if (firstChild != null) {
726                 appendNode(builder, firstChild, mode);
727             }
728         }
729 
730         builder.appendBlockSeparator();
731     }
732 
733     /**
734      * Process the given {@link HtmlTableRow}s; rows are separated by a block separator.
735      *
736      * @param builder the StringBuilder to add to
737      * @param mode the {@link Mode} to use for processing
738      * @param rows the rows
739      * @param first if true this is the first one
740      * @param skipParent1 skip row if the parent is this
741      * @param skipParent2 skip row if the parent is this
742      * @return true if no row was written so far (the new value of first)
743      */
744     protected boolean appendTableRows(final HtmlSerializerTextBuilder builder, final Mode mode,
745             final List<HtmlTableRow> rows, boolean first, final TableRowGroup skipParent1,
746             final TableRowGroup skipParent2) {
747         for (final HtmlTableRow row : rows) {
748             if (row.getParentNode() == skipParent1 || row.getParentNode() == skipParent2) {
749                 continue;
750             }
751             if (!first) {
752                 builder.appendBlockSeparator();
753             }
754             first = false;
755             appendTableRow(builder, row, mode);
756         }
757         return first;
758     }
759 
760     /**
761      * Checks if the first footer of the table is placed before the first body (or row)
762      * in the document.
763      *
764      * @param htmlTable the table
765      * @return true if the footer is before the body
766      */
767     private static boolean isFooterBeforeBody(final HtmlTable htmlTable) {
768         for (final DomNode child : htmlTable.getChildren()) {
769             if (child instanceof HtmlTableFooter) {
770                 return true;
771             }
772             if (child instanceof HtmlTableBody || child instanceof HtmlTableRow) {
773                 return false;
774             }
775         }
776         return false;
777     }
778 
779     /**
780      * Process {@link HtmlSelect}.
781      *
782      * @param builder the StringBuilder to add to
783      * @param htmlSelect the target to process
784      * @param mode the {@link Mode} to use for processing
785      */
786     protected void appendSelect(final HtmlSerializerTextBuilder builder,
787             final HtmlSelect htmlSelect, final Mode mode) {
788         // an empty select has no text and does not separate
789         final List<HtmlOption> options = htmlSelect.getOptions();
790         if (options.isEmpty()) {
791             return;
792         }
793 
794         builder.appendBlockSeparator();
795         boolean leadingNlPending = false;
796         // all options (also the ones inside of an optgroup) are rendered on a separate line;
797         // the white-space style is ignored for the options (see appendOption())
798         for (final HtmlOption item : options) {
799             if (leadingNlPending) {
800                 builder.appendBlockSeparator();
801             }
802 
803             builder.resetContentAdded();
804             appendNode(builder, item, mode);
805             leadingNlPending = builder.wasContentAdded();
806         }
807         builder.appendBlockSeparator();
808     }
809 
810     /**
811      * Process {@link HtmlOption}.
812      *
813      * @param builder the StringBuilder to add to
814      * @param htmlOption the target to process
815      * @param mode the {@link Mode} to use for processing
816      */
817     protected void appendOption(final HtmlSerializerTextBuilder builder,
818             final HtmlOption htmlOption, final Mode mode) {
819         // like WebDriver: an option is only shown inside of a select;
820         // but it is still a block
821         if (htmlOption.getEnclosingSelect() == null) {
822             builder.appendBlockSeparator();
823             builder.appendBlockSeparator();
824             return;
825         }
826 
827         // options are rendered by the select (form control); the white-space style
828         // (also the one inherited from the select) is ignored
829         for (final DomNode child : htmlOption.getChildren()) {
830             appendNode(builder, child, Mode.WHITE_SPACE_NORMAL);
831         }
832     }
833 
834     /**
835      * Process {@link HtmlOrderedList}.
836      *
837      * @param builder the StringBuilder to add to
838      * @param htmlOrderedList the OL element
839      * @param mode the {@link Mode} to use for processing
840      */
841     protected void appendOrderedList(final HtmlSerializerTextBuilder builder,
842             final HtmlOrderedList htmlOrderedList, final Mode mode) {
843         appendListItems(builder, htmlOrderedList, mode);
844     }
845 
846     /**
847      * Process {@link HtmlUnorderedList}.
848      *
849      * @param builder the StringBuilder to add to
850      * @param htmlUnorderedList the target to process
851      * @param mode the {@link Mode} to use for processing
852      */
853     protected void appendUnorderedList(final HtmlSerializerTextBuilder builder,
854                     final HtmlUnorderedList htmlUnorderedList, final Mode mode) {
855         appendListItems(builder, htmlUnorderedList, mode);
856     }
857 
858     /**
859      * Process the children of a list (ol, ul); a block level item
860      * with some content is placed on a line of its own.
861      *
862      * @param builder the StringBuilder to add to
863      * @param list the list element
864      * @param mode the {@link Mode} to use for processing
865      */
866     private void appendListItems(final HtmlSerializerTextBuilder builder,
867                     final HtmlElement list, final Mode mode) {
868         builder.appendBlockSeparator();
869         boolean leadingNlPending = false;
870         final Mode listMode = whiteSpaceStyle(list, mode);
871         for (final DomNode item : list.getChildren()) {
872             if (leadingNlPending) {
873                 builder.appendBlockSeparator();
874             }
875 
876             builder.resetContentAdded();
877             appendNode(builder, item, whiteSpaceStyle(item, listMode));
878             leadingNlPending = builder.wasContentAdded() && isBlockLevel(item);
879         }
880         builder.appendBlockSeparator();
881     }
882 
883     /**
884      * Process {@link HtmlPreformattedText}.
885      *
886      * @param builder the StringBuilder to add to
887      * @param htmlPreformattedText the target to process
888      * @param mode the {@link Mode} to use for processing
889      */
890     protected void appendPreformattedText(final HtmlSerializerTextBuilder builder,
891             final HtmlElement htmlPreformattedText, final Mode mode) {
892         if (isDisplayed(htmlPreformattedText)) {
893             builder.appendBlockSeparator();
894             appendChildren(builder, htmlPreformattedText, whiteSpaceStyle(htmlPreformattedText, Mode.PRE));
895             builder.appendBlockSeparator();
896         }
897     }
898 
899     /**
900      * Process {@link HtmlSvg}.
901      * The svg itself is inline; Chrome/Edge render the svg text elements as blocks.
902      *
903      * @param builder the StringBuilder to add to
904      * @param htmlSvg the target to process
905      * @param mode the {@link Mode} to use for processing
906      */
907     protected void appendSvg(final HtmlSerializerTextBuilder builder,
908             final HtmlSvg htmlSvg, final Mode mode) {
909         final boolean block = htmlSvg.getPage().getWebClient().getBrowserVersion()
910                     .hasFeature(JS_INNER_TEXT_SVG_NL)
911                 && hasSvgText(htmlSvg);
912         if (block) {
913             builder.appendBlockSeparator();
914         }
915         appendChildren(builder, htmlSvg, mode);
916         if (block) {
917             builder.appendBlockSeparator();
918         }
919     }
920 
921     /**
922      * Checks if the given svg node has some text that is not part of a title or desc element.
923      *
924      * @param node the node to check
925      * @return true if there is some text
926      */
927     private static boolean hasSvgText(final DomNode node) {
928         for (final DomNode child : node.getChildren()) {
929             if (child instanceof SvgTitle || child instanceof SvgDesc) {
930                 continue;
931             }
932             if (child instanceof DomText text) {
933                 if (!StringUtils.isBlank(text.getData())) {
934                     return true;
935                 }
936             }
937             else if (hasSvgText(child)) {
938                 return true;
939             }
940         }
941         return false;
942     }
943 
944     /**
945      * Process {@link HtmlInlineFrame}.
946      *
947      * @param builder the StringBuilder to add to
948      * @param htmlInlineFrame the target to process
949      * @param mode the {@link Mode} to use for processing
950      */
951     protected void appendInlineFrame(final HtmlSerializerTextBuilder builder,
952             final HtmlInlineFrame htmlInlineFrame, final Mode mode) {
953         // like WebDriver: the content of the frame is not part of the text,
954         // the iframe is an inline element with the (fallback) text of its children
955         if (isDisplayed(htmlInlineFrame)) {
956             appendChildren(builder, htmlInlineFrame, mode);
957         }
958     }
959 
960     /**
961      * Process {@link DomText}.
962      *
963      * @param builder the StringBuilder to add to
964      * @param domText the target to process
965      * @param mode the {@link Mode} to use for processing
966      */
967     protected void appendText(final HtmlSerializerTextBuilder builder, final DomText domText, final Mode mode) {
968         final DomNode parent = domText.getParentNode();
969         if (parent instanceof HtmlTitle
970                 || parent instanceof HtmlScript) {
971             builder.append(domText.getData(), Mode.WHITE_SPACE_PRE_LINE);
972             return;
973         }
974 
975         // options are not rendered by css; the display style is ignored
976         if (parent == null
977                 || parent instanceof HtmlOption
978                 || isDisplayed(parent)) {
979             String data = domText.getData();
980             // zero width space is not part of the visible text
981             if (data.indexOf('\u200B') > -1) {
982                 data = data.replace("\u200B", "");
983             }
984             builder.append(data, mode);
985         }
986     }
987 
988     /**
989      * Process {@link DomComment}.
990      *
991      * @param builder the StringBuilder to add to
992      * @param domComment the target to process
993      * @param mode the {@link Mode} to use for processing
994      */
995     protected void appendComment(final HtmlSerializerTextBuilder builder,
996             final DomComment domComment, final Mode mode) {
997         // nothing to do
998     }
999 
1000     /**
1001      * Process {@link HtmlBreak}.
1002      *
1003      * @param builder the StringBuilder to add to
1004      * @param htmlBreak the target to process
1005      * @param mode the {@link Mode} to use for processing
1006      */
1007     protected void appendBreak(final HtmlSerializerTextBuilder builder,
1008             final HtmlBreak htmlBreak, final Mode mode) {
1009         // a leading line break is not part of the visible text
1010         if (builder.isEmpty()) {
1011             return;
1012         }
1013         builder.appendBreak(mode);
1014     }
1015 
1016     /**
1017      * Process {@link HtmlCheckBoxInput}.
1018      *
1019      * @param builder the StringBuilder to add to
1020      * @param htmlCheckBoxInput the target to process
1021      * @param mode the {@link Mode} to use for processing
1022      */
1023     protected void appendCheckBoxInput(final HtmlSerializerTextBuilder builder,
1024                     final HtmlCheckBoxInput htmlCheckBoxInput, final Mode mode) {
1025         // nothing to do
1026     }
1027 
1028     /**
1029      * Process {@link HtmlRadioButtonInput}.
1030      *
1031      * @param builder the StringBuilder to add to
1032      * @param htmlRadioButtonInput the target to process
1033      * @param mode the {@link Mode} to use for processing
1034      */
1035     protected void appendRadioButtonInput(final HtmlSerializerTextBuilder builder,
1036             final HtmlRadioButtonInput htmlRadioButtonInput, final Mode mode) {
1037         // nothing to do
1038     }
1039 
1040     /**
1041      * Determines the white-space mode for the given node; the white-space style of the node
1042      * and the ancestors is checked. The result is cached for the current run.
1043      *
1044      * @param domNode the node
1045      * @param defaultMode the mode used if no white-space style is defined
1046      * @return the {@link Mode}
1047      */
1048     protected Mode whiteSpaceStyle(final DomNode domNode, final Mode defaultMode) {
1049         final Page page = domNode.getPage();
1050         if (page == null) {
1051             return defaultMode;
1052         }
1053         final WebWindow window = page.getEnclosingWindow();
1054         if (!window.getWebClient().getOptions().isCssEnabled()) {
1055             return defaultMode;
1056         }
1057 
1058         // walk up until the first node defining a white-space style or the first known result;
1059         // all the nodes on the way share this result
1060         final List<DomNode> path = new ArrayList<>();
1061         Mode mode = null;
1062         DomNode node = domNode;
1063         while (node != null) {
1064             if (whiteSpaceCache_.containsKey(node)) {
1065                 mode = whiteSpaceCache_.get(node);
1066                 break;
1067             }
1068             path.add(node);
1069             if (node instanceof DomElement element) {
1070                 mode = whiteSpaceMode(window.getComputedStyle(element, null));
1071                 if (mode != null) {
1072                     break;
1073                 }
1074             }
1075             node = node.getParentNode();
1076         }
1077 
1078         for (final DomNode pathNode : path) {
1079             whiteSpaceCache_.put(pathNode, mode);
1080         }
1081         return mode == null ? defaultMode : mode;
1082     }
1083 
1084     /**
1085      * Determines the white-space mode for the given node; only the white-space style
1086      * of the node itself is checked.
1087      *
1088      * @param domNode the node
1089      * @param defaultMode the mode used if the node does not define a white-space style
1090      * @return the {@link Mode}
1091      */
1092     protected Mode updateWhiteSpaceStyle(final DomNode domNode, final Mode defaultMode) {
1093         if (domNode instanceof DomElement element) {
1094             final Page page = element.getPage();
1095             if (page != null) {
1096                 final WebWindow window = page.getEnclosingWindow();
1097                 if (window.getWebClient().getOptions().isCssEnabled()) {
1098                     final Mode mode = whiteSpaceMode(window.getComputedStyle(element, null));
1099                     if (mode != null) {
1100                         return mode;
1101                     }
1102                 }
1103             }
1104         }
1105         return defaultMode;
1106     }
1107 
1108     /**
1109      * Maps the white-space (and white-space-collapse) style to the {@link Mode}.
1110      *
1111      * @param style the computed style
1112      * @return the {@link Mode} or {@code null} if the style does not define the white-space handling
1113      */
1114     private static Mode whiteSpaceMode(final ComputedCssStyleDeclaration style) {
1115         final String value = style.getStyleAttribute(Definition.WHITE_SPACE, false);
1116         if (!StringUtils.isEmptyOrNull(value)) {
1117             if ("normal".equalsIgnoreCase(value) || "nowrap".equalsIgnoreCase(value)) {
1118                 return Mode.WHITE_SPACE_NORMAL;
1119             }
1120             if ("pre".equalsIgnoreCase(value) || "pre-wrap".equalsIgnoreCase(value)) {
1121                 return Mode.WHITE_SPACE_PRE;
1122             }
1123             // WebDriver collapses the spaces of break-spaces but keeps the line breaks
1124             if ("pre-line".equalsIgnoreCase(value) || "break-spaces".equalsIgnoreCase(value)) {
1125                 return Mode.WHITE_SPACE_PRE_LINE;
1126             }
1127         }
1128 
1129         final String collapse = style.getStyleAttribute(Definition.WHITE_SPACE_COLLAPSE, false);
1130         if ("preserve".equalsIgnoreCase(collapse)) {
1131             return Mode.WHITE_SPACE_PRE;
1132         }
1133         if ("preserve-breaks".equalsIgnoreCase(collapse)
1134                 || "break-spaces".equalsIgnoreCase(collapse)) {
1135             return Mode.WHITE_SPACE_PRE_LINE;
1136         }
1137         return null;
1138     }
1139 
1140     /**
1141      * Helper to compose the text for the serializer based on several modes.
1142      */
1143     protected static class HtmlSerializerTextBuilder {
1144         /** Mode. */
1145         protected enum Mode {
1146             /**
1147              * The mode for the pre tag.
1148              */
1149             PRE,
1150 
1151             /**
1152              * Sequences of white space are collapsed. Newline characters
1153              * in the source are handled the same as other white space.
1154              * Lines are broken as necessary to fill line boxes.
1155              */
1156             WHITE_SPACE_NORMAL,
1157 
1158             /**
1159              * Sequences of white space are preserved. Lines are only broken
1160              * at newline characters in the source and at {@code <br>} elements.
1161              */
1162             WHITE_SPACE_PRE,
1163 
1164             /**
1165              * Sequences of white space are collapsed. Lines are broken
1166              * at newline characters, at {@code <br>} and as necessary
1167              * to fill line boxes.
1168              */
1169             WHITE_SPACE_PRE_LINE
1170         }
1171 
1172         private enum State {
1173             DEFAULT,
1174             EMPTY,
1175             BLANK_AT_END,
1176             NEWLINE_AT_END,
1177             BREAK_AT_END,
1178             BLOCK_SEPARATOR_AT_END
1179         }
1180 
1181         private State state_;
1182         private final StringBuilder builder_;
1183         private int trimRightPos_;
1184         private boolean contentAdded_;
1185         private int lineStart_;
1186 
1187         /**
1188          * Ctor.
1189          */
1190         public HtmlSerializerTextBuilder() {
1191             builder_ = new StringBuilder();
1192             state_ = State.EMPTY;
1193             trimRightPos_ = 0;
1194         }
1195 
1196         /**
1197          * Append the provided content.
1198          * see https://drafts.csswg.org/css-text-3/#white-space
1199          *
1200          * @param content the content to add
1201          * @param mode the {@link Mode}
1202          */
1203         public void append(final String content, final Mode mode) {
1204             if (content == null) {
1205                 return;
1206             }
1207             final int length = content.length();
1208             if (length == 0) {
1209                 return;
1210             }
1211 
1212             final int lastIndex = length - 1;
1213             for (int i = 0; i < length; i++) {
1214                 char c = content.charAt(i);
1215 
1216                 // a \r is ignored; only at the end it is handled like a \n
1217                 if (c == '\r') {
1218                     if (i != lastIndex) {
1219                         continue;
1220                     }
1221                     c = '\n';
1222                 }
1223 
1224                 if (c == '\n') {
1225                     if (mode == Mode.WHITE_SPACE_PRE) {
1226                         switch (state_) {
1227                             case EMPTY:
1228                             case BLOCK_SEPARATOR_AT_END:
1229                                 break;
1230                             default:
1231                                 builder_.append('\n');
1232                                 state_ = State.NEWLINE_AT_END;
1233                                 trimRightPos_ = builder_.length();
1234                                 break;
1235                         }
1236                         continue;
1237                     }
1238 
1239                     if (mode == Mode.PRE) {
1240                         // a leading line break is not part of the visible text
1241                         if (state_ == State.EMPTY) {
1242                             continue;
1243                         }
1244                         builder_.append('\n');
1245                         state_ = State.NEWLINE_AT_END;
1246                         trimRightPos_ = builder_.length();
1247 
1248                         continue;
1249                     }
1250 
1251                     if (mode == Mode.WHITE_SPACE_PRE_LINE) {
1252                         switch (state_) {
1253                             case EMPTY:
1254                             case BLOCK_SEPARATOR_AT_END:
1255                                 break;
1256                             default:
1257                                 builder_.append('\n');
1258                                 state_ = State.NEWLINE_AT_END;
1259                                 trimRightPos_ = builder_.length();
1260                                 break;
1261                         }
1262                         continue;
1263                     }
1264 
1265                     switch (state_) {
1266                         case EMPTY:
1267                         case BLANK_AT_END:
1268                         case BLOCK_SEPARATOR_AT_END:
1269                         case NEWLINE_AT_END:
1270                         case BREAK_AT_END:
1271                             break;
1272                         default:
1273                             builder_.append(' ');
1274                             state_ = State.BLANK_AT_END;
1275                             break;
1276                     }
1277                     continue;
1278                 }
1279 
1280                 if (c == ' ' || c == '\t' || c == '\f') {
1281                     if (mode == Mode.WHITE_SPACE_PRE || mode == Mode.PRE) {
1282                         appendBlank();
1283                         continue;
1284                     }
1285 
1286                     if (mode == Mode.WHITE_SPACE_PRE_LINE) {
1287                         switch (state_) {
1288                             case EMPTY:
1289                             case BLANK_AT_END:
1290                             case BREAK_AT_END:
1291                                 break;
1292                             default:
1293                                 builder_.append(' ');
1294                                 state_ = State.BLANK_AT_END;
1295                                 break;
1296                         }
1297                         continue;
1298                     }
1299 
1300                     switch (state_) {
1301                         case EMPTY:
1302                         case BLANK_AT_END:
1303                         case BLOCK_SEPARATOR_AT_END:
1304                         case NEWLINE_AT_END:
1305                         case BREAK_AT_END:
1306                             break;
1307                         default:
1308                             builder_.append(' ');
1309                             state_ = State.BLANK_AT_END;
1310                             break;
1311                     }
1312                     continue;
1313                 }
1314 
1315                 if (c == (char) 160) {
1316                     appendBlank();
1317                     if (mode == Mode.WHITE_SPACE_NORMAL || mode == Mode.WHITE_SPACE_PRE_LINE) {
1318                         state_ = State.DEFAULT;
1319                     }
1320                     continue;
1321                 }
1322                 builder_.append(c);
1323                 state_ = State.DEFAULT;
1324                 trimRightPos_ = builder_.length();
1325                 contentAdded_ = true;
1326             }
1327         }
1328 
1329         /**
1330          * Append a block separator.
1331          */
1332         public void appendBlockSeparator() {
1333             // like WebDriver: no line break if the current line contains only (preserved) white space
1334             if (state_ == State.BLANK_AT_END
1335                     && trimRightPos_ == builder_.length()
1336                     && isWhitespaceOnly(lineStart_)) {
1337                 return;
1338             }
1339 
1340             final int length = builder_.length();
1341             appendBlockSeparatorWorker();
1342             if (builder_.length() != length) {
1343                 lineStart_ = builder_.length();
1344             }
1345         }
1346 
1347         /**
1348          * Checks if the text starting at the given position contains only white space.
1349          *
1350          * @param start the start position
1351          * @return true if there is only white space
1352          */
1353         private boolean isWhitespaceOnly(final int start) {
1354             for (int i = start; i < builder_.length(); i++) {
1355                 final char c = builder_.charAt(i);
1356                 if (c != ' ' && c != '\t' && c != '\n' && c != (char) 160) {
1357                     return false;
1358                 }
1359             }
1360             return true;
1361         }
1362 
1363         /**
1364          * Append a block separator.
1365          */
1366         private void appendBlockSeparatorWorker() {
1367             switch (state_) {
1368                 case EMPTY:
1369                     break;
1370                 case BLANK_AT_END:
1371                     builder_.setLength(trimRightPos_);
1372                     if (builder_.length() == 0) {
1373                         state_ = State.EMPTY;
1374                     }
1375                     else {
1376                         builder_.append('\n');
1377                         state_ = State.BLOCK_SEPARATOR_AT_END;
1378                     }
1379                     break;
1380                 case BLOCK_SEPARATOR_AT_END:
1381                     break;
1382                 case NEWLINE_AT_END:
1383                 case BREAK_AT_END:
1384                     builder_.setLength(builder_.length() - 1);
1385                     trimRightPos_ = trimRightPos_ - 1;
1386                     if (builder_.length() == 0) {
1387                         state_ = State.EMPTY;
1388                     }
1389                     else {
1390                         builder_.append('\n');
1391                         state_ = State.BLOCK_SEPARATOR_AT_END;
1392                     }
1393                     break;
1394                 default:
1395                     builder_.append('\n');
1396                     state_ = State.BLOCK_SEPARATOR_AT_END;
1397                     break;
1398             }
1399         }
1400 
1401         /**
1402          * Append a break.
1403          *
1404          * @param mode the {@link Mode}
1405          */
1406         public void appendBreak(final Mode mode) {
1407             // a break after a block adds an empty line
1408             if (state_ != State.BLOCK_SEPARATOR_AT_END) {
1409                 builder_.setLength(trimRightPos_);
1410             }
1411 
1412             builder_.append('\n');
1413             state_ = State.BREAK_AT_END;
1414             trimRightPos_ = builder_.length();
1415             lineStart_ = builder_.length();
1416         }
1417 
1418         /**
1419          * Append a blank.
1420          */
1421         public void appendBlank() {
1422             builder_.append(' ');
1423             state_ = State.BLANK_AT_END;
1424             trimRightPos_ = builder_.length();
1425         }
1426 
1427         /**
1428          * Append the separator between two table cells; the separator is
1429          * collapsible (not added twice, removed at the end of a line).
1430          */
1431         public void appendCellSeparator() {
1432             switch (state_) {
1433                 case EMPTY:
1434                 case BLANK_AT_END:
1435                 case BLOCK_SEPARATOR_AT_END:
1436                 case NEWLINE_AT_END:
1437                 case BREAK_AT_END:
1438                     return;
1439                 default:
1440                     builder_.append(' ');
1441                     state_ = State.BLANK_AT_END;
1442             }
1443         }
1444 
1445         /**
1446          * Remove all trailing whitespace from the end.
1447          *
1448          * @param mode the {@link Mode}
1449          */
1450         public void trimRight(final Mode mode) {
1451             if (mode == Mode.PRE) {
1452                 switch (state_) {
1453                     case BLOCK_SEPARATOR_AT_END:
1454                     case NEWLINE_AT_END:
1455                     case BREAK_AT_END:
1456                         if (trimRightPos_ == builder_.length()) {
1457                             trimRightPos_--;
1458                         }
1459                         break;
1460                     default:
1461                         break;
1462                 }
1463             }
1464 
1465             builder_.setLength(trimRightPos_);
1466             state_ = State.DEFAULT;
1467             if (builder_.length() == 0) {
1468                 state_ = State.EMPTY;
1469             }
1470         }
1471 
1472         /**
1473          * Returns {@code true} if nothing was added so far.
1474          *
1475          * @return {@code true} if nothing was added so far
1476          */
1477         public boolean isEmpty() {
1478             return state_ == State.EMPTY;
1479         }
1480 
1481         /**
1482          * Returns true if some content was already added.
1483          *
1484          * @return true if some content was already added
1485          */
1486         public boolean wasContentAdded() {
1487             return contentAdded_;
1488         }
1489 
1490         /**
1491          * Resets the contentAdded state to false.
1492          */
1493         public void resetContentAdded() {
1494             contentAdded_ = false;
1495         }
1496 
1497         /**
1498          * Returns the constructed text.
1499          *
1500          * @return the constructed text.
1501          */
1502         public String getText() {
1503             return builder_.substring(0, trimRightPos_);
1504         }
1505     }
1506 }