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  import static org.htmlunit.html.DomElement.ATTRIBUTE_NOT_DEFINED;
19  
20  import java.util.IdentityHashMap;
21  import java.util.Iterator;
22  import java.util.List;
23  import java.util.Map;
24  
25  import org.htmlunit.Page;
26  import org.htmlunit.SgmlPage;
27  import org.htmlunit.WebWindow;
28  import org.htmlunit.html.DomComment;
29  import org.htmlunit.html.DomElement;
30  import org.htmlunit.html.DomNode;
31  import org.htmlunit.html.DomText;
32  import org.htmlunit.html.HtmlBody;
33  import org.htmlunit.html.HtmlBreak;
34  import org.htmlunit.html.HtmlCheckBoxInput;
35  import org.htmlunit.html.HtmlDetails;
36  import org.htmlunit.html.HtmlElement;
37  import org.htmlunit.html.HtmlElement.DisplayStyle;
38  import org.htmlunit.html.HtmlHiddenInput;
39  import org.htmlunit.html.HtmlInlineFrame;
40  import org.htmlunit.html.HtmlInput;
41  import org.htmlunit.html.HtmlListItem;
42  import org.htmlunit.html.HtmlNoFrames;
43  import org.htmlunit.html.HtmlNoScript;
44  import org.htmlunit.html.HtmlNumberInput;
45  import org.htmlunit.html.HtmlOption;
46  import org.htmlunit.html.HtmlOrderedList;
47  import org.htmlunit.html.HtmlPreformattedText;
48  import org.htmlunit.html.HtmlRadioButtonInput;
49  import org.htmlunit.html.HtmlResetInput;
50  import org.htmlunit.html.HtmlScript;
51  import org.htmlunit.html.HtmlSelect;
52  import org.htmlunit.html.HtmlStyle;
53  import org.htmlunit.html.HtmlSubmitInput;
54  import org.htmlunit.html.HtmlSummary;
55  import org.htmlunit.html.HtmlTable;
56  import org.htmlunit.html.HtmlTableCell;
57  import org.htmlunit.html.HtmlTableFooter;
58  import org.htmlunit.html.HtmlTableHeader;
59  import org.htmlunit.html.HtmlTableRow;
60  import org.htmlunit.html.HtmlTextArea;
61  import org.htmlunit.html.HtmlTitle;
62  import org.htmlunit.html.HtmlUnorderedList;
63  import org.htmlunit.html.TableRowGroup;
64  import org.htmlunit.html.serializer.HtmlSerializerNormalizedText.HtmlSerializerTextBuilder.Mode;
65  
66  /**
67   * Utility to handle conversion from HTML code to string.
68   * This implements HtmlUnit's way of normalization.
69   * <p>Note: The conversion done by selenium WebElement#getText()
70   * is different (see {@link HtmlSerializerVisibleText}).</p>
71   *
72   * @author Marc Guillemot
73   * @author Ahmed Ashour
74   * @author Ronald Brill
75   * @author Rob Kodey
76   */
77  public class HtmlSerializerNormalizedText {
78  
79      private boolean ignoreMaskedElements_ = true;
80  
81      /**
82       * Cache of the visibility of the nodes processed by the current run;
83       * the dom is not changed while serializing.
84       */
85      private final Map<DomNode, Boolean> visibilityCache_ = new IdentityHashMap<>();
86  
87      /**
88       * Converts an HTML node to text.
89       * @param node a node
90       * @return the text representation according to the setting of this serializer
91       */
92      public String asText(final DomNode node) {
93          visibilityCache_.clear();
94          final HtmlSerializerTextBuilder builder = new HtmlSerializerTextBuilder();
95          appendNode(builder, node);
96          return builder.getText();
97      }
98  
99      /**
100      * Iterates over all children and calls appendNode() for every child.
101      *
102      * @param builder the StringBuilder to add to
103      * @param node the node to process
104      */
105     protected void appendChildren(final HtmlSerializerTextBuilder builder, final DomNode node) {
106         for (final DomNode child : node.getChildren()) {
107             appendNode(builder, child);
108         }
109     }
110 
111     /**
112      * The core distribution method; calls the different appendXXX
113      * methods depending on the type of the given node.
114      *
115      * @param builder the StringBuilder to add to
116      * @param node the node to process
117      */
118     protected void appendNode(final HtmlSerializerTextBuilder builder, final DomNode node) {
119         if (node instanceof DomText text1) {
120             appendText(builder, text1);
121         }
122         else if (node instanceof DomComment) {
123             // nothing to do
124         }
125         else if (node instanceof HtmlBreak break1) {
126             appendBreak(builder, break1);
127         }
128         else if (node instanceof HtmlHiddenInput) {
129             // nothing to do
130         }
131         else if (node instanceof HtmlScript) {
132             // nothing to do
133         }
134         else if (node instanceof HtmlStyle) {
135             // nothing to do
136         }
137         else if (node instanceof HtmlNoFrames) {
138             // nothing to do
139         }
140         else if (node instanceof HtmlTextArea area) {
141             appendTextArea(builder, area);
142         }
143         else if (node instanceof HtmlTitle title) {
144             appendTitle(builder, title);
145         }
146         else if (node instanceof HtmlTableRow row) {
147             appendTableRow(builder, row);
148         }
149         else if (node instanceof HtmlSelect select) {
150             appendSelect(builder, select);
151         }
152         else if (node instanceof HtmlSubmitInput input5) {
153             appendSubmitInput(builder, input5);
154         }
155         else if (node instanceof HtmlResetInput input4) {
156             appendResetInput(builder, input4);
157         }
158         else if (node instanceof HtmlCheckBoxInput input3) {
159             appendCheckBoxInput(builder, input3);
160         }
161         else if (node instanceof HtmlRadioButtonInput input2) {
162             appendRadioButtonInput(builder, input2);
163         }
164         else if (node instanceof HtmlNumberInput input1) {
165             appendNumberInput(builder, input1);
166         }
167         else if (node instanceof HtmlInput input) {
168             appendInput(builder, input);
169         }
170         else if (node instanceof HtmlTable table) {
171             appendTable(builder, table);
172         }
173         else if (node instanceof HtmlOrderedList list1) {
174             appendOrderedList(builder, list1);
175         }
176         else if (node instanceof HtmlUnorderedList list) {
177             appendUnorderedList(builder, list);
178         }
179         else if (node instanceof HtmlPreformattedText text) {
180             appendPreformattedText(builder, text);
181         }
182         else if (node instanceof HtmlInlineFrame frame) {
183             appendInlineFrame(builder, frame);
184         }
185         else if (node instanceof HtmlDetails details) {
186             appendDetails(builder, details);
187         }
188         else if (node instanceof HtmlNoScript && node.getPage().getWebClient().isJavaScriptEnabled()) {
189             // nothing to do
190         }
191         else {
192             appendDomNode(builder, node);
193         }
194     }
195 
196     /**
197      * Process all other {@link DomNode}s; block elements are surrounded by a block separator.
198      *
199      * @param builder the StringBuilder to add to
200      * @param domNode the target to process
201      */
202     protected void appendDomNode(final HtmlSerializerTextBuilder builder, final DomNode domNode) {
203         boolean block = false;
204         if (!(domNode instanceof HtmlBody)) {
205             final SgmlPage page = domNode.getPage();
206             final WebWindow window = page.getEnclosingWindow();
207             if (window.getWebClient().getOptions().isCssEnabled()) {
208                 if (domNode instanceof DomElement element) {
209                     final String display = window.getComputedStyle(element, null).getDisplay();
210                     block = BLOCK.equals(display);
211                 }
212             }
213             else if (domNode instanceof HtmlElement element) {
214                 block = DisplayStyle.BLOCK == element.getDefaultStyleDisplay();
215             }
216         }
217 
218         if (block) {
219             builder.appendBlockSeparator();
220         }
221         appendChildren(builder, domNode);
222         if (block) {
223             builder.appendBlockSeparator();
224         }
225     }
226 
227     /**
228      * Process {@link HtmlSubmitInput}.
229      *
230      * @param builder the StringBuilder to add to
231      * @param htmlSubmitInput the target to process
232      */
233     protected void appendSubmitInput(final HtmlSerializerTextBuilder builder, final HtmlSubmitInput htmlSubmitInput) {
234         String text = htmlSubmitInput.getValueAttribute();
235         if (ATTRIBUTE_NOT_DEFINED == text) {
236             text = HtmlSubmitInput.DEFAULT_VALUE;
237         }
238 
239         builder.append(text, Mode.NORMALIZE);
240     }
241 
242     /**
243      * Process {@link HtmlInput}.
244      *
245      * @param builder the StringBuilder to add to
246      * @param htmlInput the target to process
247      */
248     protected void appendInput(final HtmlSerializerTextBuilder builder, final HtmlInput htmlInput) {
249         builder.append(" ", Mode.NORMALIZE);
250         builder.append(htmlInput.getRawValue(), Mode.NORMALIZE);
251         builder.append(" ", Mode.NORMALIZE);
252     }
253 
254     /**
255      * Process {@link HtmlNumberInput}.
256      *
257      * @param builder the StringBuilder to add to
258      * @param htmlNumberInput the target to process
259      */
260     protected void appendNumberInput(final HtmlSerializerTextBuilder builder, final HtmlNumberInput htmlNumberInput) {
261         builder.append(" ", Mode.NORMALIZE);
262 
263         String val = htmlNumberInput.getRawValue();
264         final int lastPos = val.length() - 1;
265         if (lastPos >= 0 && val.charAt(lastPos) == '.') {
266             val = val.substring(0, lastPos);
267         }
268         builder.append(val, Mode.NORMALIZE);
269 
270         builder.append(" ", Mode.NORMALIZE);
271     }
272 
273     /**
274      * Process {@link HtmlResetInput}.
275      *
276      * @param builder the StringBuilder to add to
277      * @param htmlResetInput the target to process
278      */
279     protected void appendResetInput(final HtmlSerializerTextBuilder builder, final HtmlResetInput htmlResetInput) {
280         String text = htmlResetInput.getValueAttribute();
281         if (ATTRIBUTE_NOT_DEFINED == text) {
282             text = HtmlResetInput.DEFAULT_VALUE;
283         }
284 
285         builder.append(text, Mode.NORMALIZE);
286     }
287 
288     /**
289      * Process {@link HtmlUnorderedList}.
290      *
291      * @param builder the StringBuilder to add to
292      * @param htmlUnorderedList the target to process
293      */
294     protected void appendUnorderedList(final HtmlSerializerTextBuilder builder,
295                                           final HtmlUnorderedList htmlUnorderedList) {
296         builder.appendBlockSeparator();
297         boolean first = true;
298         for (final DomNode item : htmlUnorderedList.getChildren()) {
299             if (!first) {
300                 builder.appendBlockSeparator();
301             }
302             first = false;
303             appendNode(builder, item);
304         }
305         builder.appendBlockSeparator();
306     }
307 
308     /**
309      * Process {@link HtmlDetails}.
310      *
311      * @param builder the StringBuilder to add to
312      * @param htmlDetails the target to process
313      */
314     protected void appendDetails(final HtmlSerializerTextBuilder builder,
315                     final HtmlDetails htmlDetails) {
316         if (htmlDetails.isOpen()) {
317             appendChildren(builder, htmlDetails);
318             return;
319         }
320 
321         for (final DomNode child : htmlDetails.getChildren()) {
322             if (child instanceof HtmlSummary) {
323                 // only the first summary is the summary of the details
324                 // but for the moment i like to have it like this for backward compatibility
325                 appendNode(builder, child);
326             }
327         }
328     }
329 
330     /**
331      * Process {@link HtmlTitle}.
332      *
333      * @param builder the StringBuilder to add to
334      * @param htmlTitle the target to process
335      */
336     protected void appendTitle(final HtmlSerializerTextBuilder builder, final HtmlTitle htmlTitle) {
337         // optimized version
338         // for the title there is no need to check the visibility
339         // of the containing dom text;
340         // this optimization defers the load of the style sheets
341         final DomNode child = htmlTitle.getFirstChild();
342         if (child instanceof DomText text) {
343             builder.append(text.getData(), Mode.NORMALIZE);
344             builder.appendBlockSeparator();
345         }
346     }
347 
348     /**
349      * Process {@link HtmlTableRow}.
350      *
351      * @param builder the StringBuilder to add to
352      * @param htmlTableRow the target to process
353      */
354     protected void appendTableRow(final HtmlSerializerTextBuilder builder, final HtmlTableRow htmlTableRow) {
355         boolean first = true;
356         for (final HtmlTableCell cell : htmlTableRow.getCells()) {
357             if (first) {
358                 first = false;
359             }
360             else {
361                 builder.appendTab();
362             }
363             appendChildren(builder, cell);
364         }
365     }
366 
367     /**
368      * Process {@link HtmlTextArea}.
369      *
370      * @param builder the StringBuilder to add to
371      * @param htmlTextArea the target to process
372      */
373     protected void appendTextArea(final HtmlSerializerTextBuilder builder, final HtmlTextArea htmlTextArea) {
374         if (isVisible(htmlTextArea)) {
375             builder.append(htmlTextArea.getText(), Mode.PRESERVE_BLANK_NEWLINE);
376         }
377     }
378 
379     /**
380      * Process {@link HtmlTable}.
381      *
382      * @param builder the StringBuilder to add to
383      * @param htmlTable the target to process
384      */
385     protected void appendTable(final HtmlSerializerTextBuilder builder, final HtmlTable htmlTable) {
386         builder.appendBlockSeparator();
387         final String caption = htmlTable.getCaptionText();
388         if (caption != null) {
389             builder.append(caption, Mode.NORMALIZE);
390             builder.appendBlockSeparator();
391         }
392 
393         boolean first = true;
394 
395         // first thead has to be displayed first and first tfoot has to be displayed last
396         final HtmlTableHeader tableHeader = htmlTable.getHeader();
397         if (tableHeader != null) {
398             first = appendTableRows(builder, tableHeader.getRows(), true, null, null);
399         }
400         final HtmlTableFooter tableFooter = htmlTable.getFooter();
401 
402         final List<HtmlTableRow> tableRows = htmlTable.getRows();
403         first = appendTableRows(builder, tableRows, first, tableHeader, tableFooter);
404 
405         if (tableFooter != null) {
406             first = appendTableRows(builder, tableFooter.getRows(), first, null, null);
407         }
408         else if (tableRows.isEmpty()) {
409             final DomNode firstChild = htmlTable.getFirstChild();
410             if (firstChild != null) {
411                 appendNode(builder, firstChild);
412             }
413         }
414 
415         builder.appendBlockSeparator();
416     }
417 
418     /**
419      * Process the given {@link HtmlTableRow}s; rows are separated by a block separator.
420      *
421      * @param builder the StringBuilder to add to
422      * @param rows the rows
423      * @param first if true this is the first one
424      * @param skipParent1 skip row if the parent is this
425      * @param skipParent2 skip row if the parent is this
426      * @return true if no row was written so far (the new value of first)
427      */
428     protected boolean appendTableRows(final HtmlSerializerTextBuilder builder,
429             final List<HtmlTableRow> rows, boolean first, final TableRowGroup skipParent1,
430             final TableRowGroup skipParent2) {
431         for (final HtmlTableRow row : rows) {
432             if (row.getParentNode() == skipParent1 || row.getParentNode() == skipParent2) {
433                 continue;
434             }
435             if (!first) {
436                 builder.appendBlockSeparator();
437             }
438             first = false;
439             appendTableRow(builder, row);
440         }
441         return first;
442     }
443 
444     /**
445      * Process {@link HtmlSelect}.
446      *
447      * @param builder the StringBuilder to add to
448      * @param htmlSelect the target to process
449      */
450     protected void appendSelect(final HtmlSerializerTextBuilder builder, final HtmlSelect htmlSelect) {
451         final List<HtmlOption> options = htmlSelect.getSelectedOptions();
452 
453         for (final Iterator<HtmlOption> i = options.iterator(); i.hasNext();) {
454             final HtmlOption currentOption = i.next();
455             appendChildren(builder, currentOption);
456             if (i.hasNext()) {
457                 builder.appendBlockSeparator();
458             }
459         }
460     }
461 
462     /**
463      * Process {@link HtmlOrderedList} taking care to numerate it.
464      *
465      * @param builder the StringBuilder to add to
466      * @param htmlOrderedList the OL element
467      */
468     protected void appendOrderedList(final HtmlSerializerTextBuilder builder, final HtmlOrderedList htmlOrderedList) {
469         builder.appendBlockSeparator();
470         boolean first = true;
471         int i = 1;
472 
473         final String start = htmlOrderedList.getStartAttribute();
474         if (ATTRIBUTE_NOT_DEFINED != start) {
475             try {
476                 i = (int) Math.round(Double.parseDouble(start));
477             }
478             catch (final Exception e) {
479                 // ignore start with 1
480             }
481         }
482 
483         for (final DomNode item : htmlOrderedList.getChildren()) {
484             if (!first) {
485                 builder.appendBlockSeparator();
486             }
487             first = false;
488             if (item instanceof HtmlListItem) {
489                 builder.append(Integer.toString(i++), Mode.NORMALIZE);
490                 builder.append(". ", Mode.NORMALIZE);
491                 appendChildren(builder, item);
492             }
493             else {
494                 appendNode(builder, item);
495             }
496         }
497         builder.appendBlockSeparator();
498     }
499 
500     /**
501      * Process {@link HtmlPreformattedText}.
502      *
503      * @param builder the StringBuilder to add to
504      * @param htmlPreformattedText the target to process
505      */
506     protected void appendPreformattedText(final HtmlSerializerTextBuilder builder,
507             final HtmlPreformattedText htmlPreformattedText) {
508         if (isVisible(htmlPreformattedText)) {
509             builder.appendBlockSeparator();
510             builder.append(htmlPreformattedText.getTextContent(), Mode.PRESERVE_BLANK_TAB_NEWLINE);
511             builder.appendBlockSeparator();
512         }
513     }
514 
515     /**
516      * Process {@link HtmlInlineFrame}.
517      *
518      * @param builder the StringBuilder to add to
519      * @param htmlInlineFrame the target to process
520      */
521     protected void appendInlineFrame(final HtmlSerializerTextBuilder builder,
522             final HtmlInlineFrame htmlInlineFrame) {
523         if (isVisible(htmlInlineFrame)) {
524             builder.appendBlockSeparator();
525             final Page page = htmlInlineFrame.getEnclosedPage();
526             if (page instanceof SgmlPage sgmlPage) {
527                 builder.append(sgmlPage.asNormalizedText(), Mode.NORMALIZE);
528             }
529             builder.appendBlockSeparator();
530         }
531     }
532 
533     /**
534      * Process {@link DomText}.
535      *
536      * @param builder the StringBuilder to add to
537      * @param domText the target to process
538      */
539     protected void appendText(final HtmlSerializerTextBuilder builder, final DomText domText) {
540         final DomNode parent = domText.getParentNode();
541         if (parent == null || parent instanceof HtmlTitle || isVisible(parent)) {
542             builder.append(domText.getData(), Mode.NORMALIZE);
543         }
544     }
545 
546     /**
547      * Process {@link HtmlBreak}.
548      *
549      * @param builder the StringBuilder to add to
550      * @param htmlBreak the target to process
551      */
552     protected void appendBreak(final HtmlSerializerTextBuilder builder, final HtmlBreak htmlBreak) {
553         builder.appendNewLine();
554     }
555 
556     /**
557      * Process {@link HtmlCheckBoxInput}.
558      *
559      * @param builder the StringBuilder to add to
560      * @param htmlCheckBoxInput the target to process
561      */
562     protected void appendCheckBoxInput(final HtmlSerializerTextBuilder builder,
563                                             final HtmlCheckBoxInput htmlCheckBoxInput) {
564         appendCheckedState(builder, htmlCheckBoxInput.isChecked());
565     }
566 
567     /**
568      * Process {@link HtmlRadioButtonInput}.
569      *
570      * @param builder the StringBuilder to add to
571      * @param htmlRadioButtonInput the target to process
572      */
573     protected void appendRadioButtonInput(final HtmlSerializerTextBuilder builder,
574             final HtmlRadioButtonInput htmlRadioButtonInput) {
575         appendCheckedState(builder, htmlRadioButtonInput.isChecked());
576     }
577 
578     /**
579      * Appends the state of a checkbox or radio button.
580      *
581      * @param builder the StringBuilder to add to
582      * @param checked the checked state
583      */
584     private static void appendCheckedState(final HtmlSerializerTextBuilder builder, final boolean checked) {
585         builder.append(checked ? "checked " : "unchecked ", Mode.NORMALIZE);
586     }
587 
588     /**
589      * Checks if the given node is visible; if masked elements are not ignored
590      * every node is visible. The result is cached for the current run because
591      * {@link DomNode#isDisplayed()} has to check all the ancestors and
592      * this is done for every text node.
593      *
594      * @param node the node to check
595      * @return true if the node is visible
596      */
597     private boolean isVisible(final DomNode node) {
598         if (!ignoreMaskedElements_) {
599             return true;
600         }
601         return visibilityCache_.computeIfAbsent(node, DomNode::isDisplayed);
602     }
603 
604     /**
605      * Indicates if element that are not displayed due to style settings
606      * (visibility or display) should be visible in generated text.
607      * @param ignore indicates if masked elements should be ignored or not
608      */
609     public void setIgnoreMaskedElements(final boolean ignore) {
610         ignoreMaskedElements_ = ignore;
611     }
612 
613     /**
614      * Helper to compose the text for the serializer based on several modes.
615      */
616     protected static class HtmlSerializerTextBuilder {
617 
618         /** Mode. */
619         protected enum Mode {
620             /** Collapse whitespace. */
621             NORMALIZE,
622 
623             /** Preserve tab, blank, newline. */
624             PRESERVE_BLANK_TAB_NEWLINE,
625 
626             /** Preserve blank, newline. */
627             PRESERVE_BLANK_NEWLINE
628         }
629 
630         private enum State {
631             DEFAULT,
632             EMPTY,
633             TRIM,
634             BLANK_AT_END,
635             BLANK_AT_END_AFTER_NEWLINE,
636             NEWLINE_AT_END,
637             BLOCK_SEPARATOR_AT_END
638         }
639 
640         private static final String LINE_SEPARATOR = "\n";
641         private static final int LINE_SEPARATOR_LENGTH = LINE_SEPARATOR.length();
642 
643         private State state_;
644         private final StringBuilder builder_;
645         private int trimRightPos_;
646 
647         /**
648          * Ctor.
649          */
650         public HtmlSerializerTextBuilder() {
651             builder_ = new StringBuilder();
652             state_ = State.EMPTY;
653             trimRightPos_ = builder_.length();
654         }
655 
656         /**
657          * Append the provided content.
658          *
659          * @param content the content to add
660          * @param mode the {@link Mode}
661          */
662         public void append(final String content, final Mode mode) {
663             if (content == null) {
664                 return;
665             }
666             final int length = content.length();
667             if (length == 0) {
668                 return;
669             }
670 
671             // Mode.PRESERVE_BLANK_NEWLINE only differs in the handling of tabs;
672             // it is used for textareas
673 
674             boolean crFound = false;
675             for (int i = 0; i < length; i++) {
676                 final char c = content.charAt(i);
677 
678                 if (mode == Mode.NORMALIZE) {
679                     if (isSpace(c)) {
680                         switch (state_) {
681                             case EMPTY:
682                             case TRIM:
683                             case BLANK_AT_END:
684                             case BLANK_AT_END_AFTER_NEWLINE:
685                             case BLOCK_SEPARATOR_AT_END:
686                                 break;
687                             case NEWLINE_AT_END:
688                                 builder_.append(' ');
689                                 state_ = State.BLANK_AT_END_AFTER_NEWLINE;
690                                 break;
691                             default:
692                                 builder_.append(' ');
693                                 state_ = State.BLANK_AT_END;
694                                 break;
695                         }
696                     }
697                     else if (c == (char) 160) {
698                         builder_.append(' ');
699                         state_ = State.DEFAULT;
700                         trimRightPos_ = builder_.length();
701                     }
702                     else {
703                         builder_.append(c);
704                         state_ = State.DEFAULT;
705                         trimRightPos_ = builder_.length();
706                     }
707                     continue;
708                 }
709 
710                 // preserve mode
711                 if (c == '\n') {
712                     appendNewLine();
713                     crFound = false;
714                 }
715                 else {
716                     if (crFound) {
717                         appendNewLine();
718                     }
719                     crFound = c == '\r';
720 
721                     if (c == '\t') {
722                         if (mode == Mode.PRESERVE_BLANK_TAB_NEWLINE) {
723                             appendTab();
724                         }
725                         else if (state_ != State.BLOCK_SEPARATOR_AT_END) {
726                             builder_.append(' ');
727                         }
728                     }
729                     else if (c == (char) 160) {
730                         appendBlank();
731                     }
732                     else if (c == ' ') {
733                         appendBlank();
734                     }
735                     else {
736                         builder_.append(c);
737                     }
738                     trimRightPos_ = builder_.length();
739                 }
740             }
741 
742             if (crFound) {
743                 appendNewLine();
744             }
745 
746             if (mode != Mode.NORMALIZE) {
747                 // reset state to empty to restart whitespace normalization afterward
748                 state_ = State.TRIM;
749             }
750         }
751 
752         /**
753          * Append a block separator.
754          */
755         public void appendBlockSeparator() {
756             switch (state_) {
757                 case EMPTY:
758                     break;
759                 case BLANK_AT_END:
760                     builder_.setLength(trimRightPos_);
761                     if (builder_.length() == 0) {
762                         state_ = State.EMPTY;
763                     }
764                     else {
765                         builder_.append(LINE_SEPARATOR);
766                         state_ = State.BLOCK_SEPARATOR_AT_END;
767                     }
768                     break;
769                 case BLANK_AT_END_AFTER_NEWLINE:
770                     builder_.setLength(trimRightPos_ - LINE_SEPARATOR_LENGTH);
771                     trimRightPos_ = trimRightPos_ - LINE_SEPARATOR_LENGTH;
772                     if (builder_.length() == 0) {
773                         state_ = State.EMPTY;
774                     }
775                     else {
776                         builder_.append(LINE_SEPARATOR);
777                         state_ = State.BLOCK_SEPARATOR_AT_END;
778                     }
779                     break;
780                 case BLOCK_SEPARATOR_AT_END:
781                     break;
782                 case NEWLINE_AT_END:
783                     builder_.setLength(builder_.length() - LINE_SEPARATOR_LENGTH);
784                     trimRightPos_ = trimRightPos_ - LINE_SEPARATOR_LENGTH;
785                     if (builder_.length() == 0) {
786                         state_ = State.EMPTY;
787                     }
788                     else {
789                         builder_.append(LINE_SEPARATOR);
790                         state_ = State.BLOCK_SEPARATOR_AT_END;
791                     }
792                     break;
793                 default:
794                     builder_.append(LINE_SEPARATOR);
795                     state_ = State.BLOCK_SEPARATOR_AT_END;
796                     break;
797             }
798         }
799 
800         /**
801          * Append a line separator.
802          */
803         public void appendNewLine() {
804             builder_.append(LINE_SEPARATOR);
805             state_ = State.NEWLINE_AT_END;
806             trimRightPos_ = builder_.length();
807         }
808 
809         /**
810          * Append a tab.
811          */
812         public void appendTab() {
813             builder_.append('\t');
814             trimRightPos_ = builder_.length();
815         }
816 
817         /**
818          * Append a blank.
819          */
820         private void appendBlank() {
821             builder_.append(' ');
822             trimRightPos_ = builder_.length();
823         }
824 
825         /**
826          * Returns the constructed text.
827          *
828          * @return the constructed text.
829          */
830         public String getText() {
831             return builder_.substring(0, trimRightPos_);
832         }
833 
834         private static boolean isSpace(final char ch) {
835             return ch == ' ' || ch == '\t' || ch == '\n' || ch == '\f' || ch == '\r';
836         }
837     }
838 }