001/* 002 * This library is part of OpenCms - 003 * the Open Source Content Management System 004 * 005 * Copyright (c) Alkacon Software GmbH & Co. KG (https://www.alkacon.com) 006 * 007 * This library is free software; you can redistribute it and/or 008 * modify it under the terms of the GNU Lesser General Public 009 * License as published by the Free Software Foundation; either 010 * version 2.1 of the License, or (at your option) any later version. 011 * 012 * This library is distributed in the hope that it will be useful, 013 * but WITHOUT ANY WARRANTY; without even the implied warranty of 014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 015 * Lesser General Public License for more details. 016 * 017 * For further information about Alkacon Software GmbH & Co. KG, please see the 018 * company website: https://www.alkacon.com 019 * 020 * For further information about OpenCms, please see the 021 * project website: https://www.opencms.org 022 * 023 * You should have received a copy of the GNU Lesser General Public 024 * License along with this library; if not, write to the Free Software 025 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 026 */ 027 028package org.opencms.util; 029 030import java.util.ArrayList; 031import java.util.HashMap; 032import java.util.List; 033import java.util.Map; 034import java.util.Set; 035 036import org.jsoup.Jsoup; 037import org.jsoup.nodes.Document; 038import org.jsoup.nodes.Element; 039import org.jsoup.nodes.Node; 040import org.jsoup.nodes.TextNode; 041 042/** 043 * Converts an HTML fragment to Markdown.<p> 044 * 045 * This converter is intended for HTML <i>pieces</i> (rich text fragments), not complete HTML pages. 046 * It parses the input with Jsoup using {@link Jsoup#parseBodyFragment(String)} and renders a common 047 * set of Markdown constructs: headings, paragraphs, bold and italic, inline code, links, images, 048 * ordered and unordered (including nested) lists, blockquotes, fenced code blocks and horizontal rules.<p> 049 * 050 * Tags that are not recognized are passed through, so their textual content is preserved. The main 051 * use case is to feed CMS content into LLM prompts in a compact, model friendly form.<p> 052 * 053 * Note: only a minimal set of Markdown special characters is escaped in text nodes. Perfect round 054 * trip escaping (for example of a leading "#", "-" or ">" at the start of a line) is out of scope. 055 * GFM tables, task lists and strikethrough are not supported.<p> 056 * 057 * @since 22.0.0 058 */ 059public class CmsHtml2MarkdownConverter { 060 061 /** The block level tags that are rendered as separate Markdown blocks. */ 062 private static final Set<String> BLOCK_TAGS = Set.of( 063 "h1", 064 "h2", 065 "h3", 066 "h4", 067 "h5", 068 "h6", 069 "p", 070 "div", 071 "section", 072 "article", 073 "header", 074 "footer", 075 "figure", 076 "figcaption", 077 "blockquote", 078 "ul", 079 "ol", 080 "pre", 081 "hr", 082 "table"); 083 084 /** The Markdown special characters that are escaped in text nodes. */ 085 private static final Set<Character> ESCAPE_CHARS = Set.of( 086 Character.valueOf('\\'), 087 Character.valueOf('`'), 088 Character.valueOf('*'), 089 Character.valueOf('_'), 090 Character.valueOf('['), 091 Character.valueOf(']')); 092 093 /** Punctuation that attaches to the preceding word, so no separating space is inserted before it. */ 094 private static final String ATTACHING_PUNCTUATION = ".,:;!?)]}"; 095 096 /** Maps typographic symbols and punctuation to their plain ASCII replacement (keyed by code point). */ 097 private static final Map<Character, String> SYMBOL_FOLD = createSymbolFold(); 098 099 /** Whether images and their related markup are dropped from the output. */ 100 private final boolean m_ignoreImages; 101 102 /** Whether links are rendered as their text only, dropping the link markup. */ 103 private final boolean m_removeLinks; 104 105 /** 106 * Creates a new converter that keeps images and links untouched.<p> 107 */ 108 public CmsHtml2MarkdownConverter() { 109 110 this(false, false); 111 } 112 113 /** 114 * Creates a new converter.<p> 115 * 116 * @param ignoreImages if <code>true</code>, images and their related markup are dropped from the output 117 * @param removeLinks if <code>true</code>, links are rendered as their text only, dropping the link markup 118 */ 119 public CmsHtml2MarkdownConverter(boolean ignoreImages, boolean removeLinks) { 120 121 m_ignoreImages = ignoreImages; 122 m_removeLinks = removeLinks; 123 } 124 125 /** 126 * Converts the given HTML fragment to Markdown.<p> 127 * 128 * @param html the HTML fragment to convert 129 * 130 * @return the Markdown representation of the given HTML fragment 131 */ 132 public static String html2markdown(String html) { 133 134 return html2markdown(html, false, false); 135 } 136 137 /** 138 * Converts the given HTML fragment to Markdown.<p> 139 * 140 * @param html the HTML fragment to convert 141 * @param ignoreImages if <code>true</code>, images and their related markup are dropped from the output 142 * @param removeLinks if <code>true</code>, links are rendered as their text only, dropping the link markup 143 * 144 * @return the Markdown representation of the given HTML fragment 145 */ 146 public static String html2markdown(String html, boolean ignoreImages, boolean removeLinks) { 147 148 return new CmsHtml2MarkdownConverter(ignoreImages, removeLinks).convert(html); 149 } 150 151 /** 152 * Builds the symbol-to-ASCII fold map following the NLM Lexical Tools mapSymbolToAscii table.<p> 153 * 154 * Invisible characters (soft hyphen, zero width spaces, word joiner, byte order mark) are not folded 155 * here; they are removed earlier in <code>{@link #normalizeWhitespace(String)}</code>.<p> 156 * 157 * @return the symbol-to-ASCII fold map 158 */ 159 private static Map<Character, String> createSymbolFold() { 160 161 Map<Character, String> map = new HashMap<Character, String>(128); 162 putFold(map, "-", 0x2010, 0x2011, 0x2012, 0x2013, 0x2014, 0x2212); 163 putFold(map, "--", 0x2015); 164 putFold( 165 map, 166 Character.toString('"'), 167 0x00AB, 168 0x00BB, 169 0x02BA, 170 0x030B, 171 0x030E, 172 0x201C, 173 0x201D, 174 0x201E, 175 0x201F, 176 0x2033, 177 0x2036, 178 0x3003, 179 0x301D, 180 0x301E); 181 putFold(map, "'", 0x00B4, 0x02B9, 0x02BC, 0x02C8, 0x0301, 0x2018, 0x2019, 0x201B, 0x2032); 182 putFold(map, "'''", 0x2034, 0x2037); 183 putFold(map, "`", 0x02CB, 0x0300, 0x2035); 184 putFold(map, "_", 0x02CD, 0x0331, 0x0332, 0x2017); 185 putFold(map, "~", 0x02DC, 0x0303, 0x2053, 0x223C, 0x301C); 186 putFold(map, "^", 0x02C4, 0x02C6, 0x0302, 0x2038, 0x2303); 187 putFold(map, "/", 0x00F7, 0x0338, 0x2044, 0x2215); 188 putFold(map, Character.toString('\\'), 0x20E5, 0x2216); 189 putFold(map, "|", 0x01C0, 0x05C0, 0x2223, 0x2758); 190 putFold(map, "||", 0x2016); 191 putFold(map, "!", 0x01C3, 0x2762); 192 putFold(map, ":", 0x0589, 0x05C3, 0x2236); 193 putFold(map, "%", 0x066A, 0x2052); 194 putFold(map, "*", 0x066D, 0x204E, 0x2217, 0x2731); 195 putFold(map, "<", 0x2039, 0x2329, 0x27E8, 0x3008); 196 putFold(map, ">", 0x203A, 0x232A, 0x27E9, 0x3009); 197 putFold(map, "<=", 0x2264, 0x2266); 198 putFold(map, ">=", 0x2265, 0x2267); 199 putFold(map, "[", 0x27E6); 200 putFold(map, "]", 0x301B); 201 putFold(map, "{", 0x2983); 202 putFold(map, "}", 0x2984); 203 putFold(map, "#", 0x266F); 204 putFold(map, "?", 0x203D); 205 putFold(map, ",", 0x201A); 206 putFold(map, "...", 0x2026); 207 return map; 208 } 209 210 /** 211 * Detects the code language from a "language-xxx" CSS class, if present.<p> 212 * 213 * @param element the element to inspect 214 * 215 * @return the detected language, or the empty String if none was found 216 */ 217 private static String detectLanguage(Element element) { 218 219 for (String name : element.classNames()) { 220 if (name.startsWith("language-")) { 221 return name.substring("language-".length()); 222 } 223 } 224 return ""; 225 } 226 227 /** 228 * Returns whether the given buffer ends with a whitespace character.<p> 229 * 230 * @param buffer the buffer to check 231 * 232 * @return <code>true</code> if the buffer ends with whitespace 233 */ 234 private static boolean endsWithWhitespace(StringBuilder buffer) { 235 236 return (buffer.length() > 0) && Character.isWhitespace(buffer.charAt(buffer.length() - 1)); 237 } 238 239 /** 240 * Backslash escapes a minimal set of Markdown special characters in the given text.<p> 241 * 242 * @param text the text to escape 243 * 244 * @return the escaped text 245 */ 246 private static String escape(String text) { 247 248 StringBuilder result = new StringBuilder(text.length()); 249 for (int i = 0; i < text.length(); i++) { 250 char c = text.charAt(i); 251 if (ESCAPE_CHARS.contains(Character.valueOf(c))) { 252 result.append('\\'); 253 } 254 result.append(c); 255 } 256 return result.toString(); 257 } 258 259 /** 260 * Appends the given inline buffer as a trimmed block to the given list, then resets the buffer.<p> 261 * 262 * @param inline the inline buffer 263 * @param blocks the list of blocks to append to 264 */ 265 private static void flushInline(StringBuilder inline, List<String> blocks) { 266 267 String text = inline.toString().trim(); 268 if (!text.isEmpty()) { 269 blocks.add(text); 270 } 271 inline.setLength(0); 272 } 273 274 /** 275 * Folds well known typographic symbols and punctuation to plain ASCII, following the NLM Lexical 276 * Tools symbol-to-ASCII mapping table (mapSymbolToAscii).<p> 277 * 278 * Dashes and the minus sign become a hyphen; typographic, angle and prime quotes become straight 279 * quotes or apostrophes; the ellipsis becomes three dots; and fancy slashes, bars, brackets, 280 * asterisks, carets, tildes, accents and comparison signs become their ASCII counterparts. Where a 281 * replacement is itself a Markdown special character it is escaped afterwards by 282 * <code>{@link #escape(String)}</code>.<p> 283 * 284 * @param text the text to fold 285 * 286 * @return the text with typographic symbols replaced by ASCII equivalents 287 */ 288 private static String foldPunctuation(String text) { 289 290 StringBuilder result = new StringBuilder(text.length()); 291 for (int i = 0; i < text.length(); i++) { 292 char c = text.charAt(i); 293 String replacement = SYMBOL_FOLD.get(Character.valueOf(c)); 294 if (replacement != null) { 295 result.append(replacement); 296 } else { 297 result.append(c); 298 } 299 } 300 return result.toString(); 301 } 302 303 /** 304 * Returns whether the given node must be rendered as a separate Markdown block.<p> 305 * 306 * A node is a block when its tag is a block level tag, or when it is an otherwise inline element 307 * (for example a link wrapping a whole card) that contains block level content; in the latter 308 * case it is unwrapped and rendered as blocks, so the wrapped headings and paragraphs keep their 309 * structure instead of being flattened to inline text.<p> 310 * 311 * @param node the node to check 312 * 313 * @return <code>true</code> if the node must be rendered as a block 314 */ 315 private static boolean isBlock(Node node) { 316 317 if (!(node instanceof Element)) { 318 return false; 319 } 320 Element element = (Element)node; 321 if (BLOCK_TAGS.contains(element.tagName())) { 322 return true; 323 } 324 for (Node child : element.childNodes()) { 325 if (isBlock(child)) { 326 return true; 327 } 328 } 329 return false; 330 } 331 332 /** 333 * Returns whether the given element is an ordered or unordered list.<p> 334 * 335 * @param element the element to check 336 * 337 * @return <code>true</code> if the element is a ul or ol 338 */ 339 private static boolean isList(Element element) { 340 341 return "ul".equals(element.tagName()) || "ol".equals(element.tagName()); 342 } 343 344 /** 345 * Returns whether the given node is a structural or empty element whose boundary with adjacent 346 * content should be separated by a space. Text nodes and inline markup elements (bold, italic, 347 * inline code, links, line breaks, images and dropped script / style) are not separating, so they 348 * keep the whitespace as it is in the source.<p> 349 * 350 * @param node the node to check 351 * 352 * @return <code>true</code> if the node's boundary should get a separating space 353 */ 354 private static boolean isSeparating(Node node) { 355 356 if (!(node instanceof Element)) { 357 return false; 358 } 359 switch (((Element)node).tagName()) { 360 case "a": 361 case "b": 362 case "br": 363 case "code": 364 case "em": 365 case "i": 366 case "img": 367 case "script": 368 case "strong": 369 case "style": 370 return false; 371 default: 372 return true; 373 } 374 } 375 376 /** 377 * Normalizes the whitespace in the given text for Markdown output.<p> 378 * 379 * Zero width and invisible formatting characters (soft hyphen, zero width spaces, word joiner, 380 * byte order mark) are dropped; runs of whitespace, including non breaking and other Unicode 381 * spaces, are collapsed to a single regular space (a single leading and trailing space is kept).<p> 382 * 383 * @param text the text to normalize 384 * 385 * @return the normalized text 386 */ 387 private static String normalizeWhitespace(String text) { 388 389 // drop zero width and invisible formatting characters (soft hyphen, ZWSP, ZWNJ, ZWJ, word joiner, BOM) 390 String cleaned = text.replaceAll("[\\u00AD\\u200B\\u200C\\u200D\\u2060\\uFEFF]", ""); 391 // collapse whitespace, including non breaking and other Unicode spaces, to a single regular space 392 return cleaned.replaceAll("[\\s\\u0085\\u00A0\\u1680\\u2000-\\u200A\\u2028\\u2029\\u202F\\u205F\\u3000]+", " "); 393 } 394 395 /** 396 * Puts a fold mapping for each of the given code points into the given map.<p> 397 * 398 * @param map the map to fill 399 * @param replacement the ASCII replacement string 400 * @param codePoints the source code points that map to the replacement 401 */ 402 private static void putFold(Map<Character, String> map, String replacement, int... codePoints) { 403 404 for (int codePoint : codePoints) { 405 map.put(Character.valueOf((char)codePoint), replacement); 406 } 407 } 408 409 /** 410 * Renders an inline code element.<p> 411 * 412 * @param element the code element 413 * 414 * @return the rendered inline code, or the empty String if there is no content 415 */ 416 private static String renderInlineCode(Element element) { 417 418 String code = element.text(); 419 if (CmsStringUtil.isEmptyOrWhitespaceOnly(code)) { 420 return ""; 421 } 422 return "`" + code + "`"; 423 } 424 425 /** 426 * Renders a pre element as a fenced Markdown code block.<p> 427 * 428 * @param element the pre element 429 * 430 * @return the rendered fenced code block 431 */ 432 private static String renderPre(Element element) { 433 434 Element codeElement = element.selectFirst("code"); 435 String language = detectLanguage(codeElement != null ? codeElement : element); 436 String code = (codeElement != null ? codeElement : element).wholeText(); 437 // strip a trailing line break to avoid an empty line before the closing fence 438 code = code.replaceAll("\\n+$", ""); 439 return "```" + language + "\n" + code + "\n```"; 440 } 441 442 /** 443 * Returns whether the given text starts with a punctuation character that attaches to the 444 * preceding word (see {@link #ATTACHING_PUNCTUATION}), so no separating space should precede it.<p> 445 * 446 * @param text the text to check 447 * 448 * @return <code>true</code> if the text starts with attaching punctuation 449 */ 450 private static boolean startsWithPunctuation(String text) { 451 452 return !text.isEmpty() && (ATTACHING_PUNCTUATION.indexOf(text.charAt(0)) >= 0); 453 } 454 455 /** 456 * Returns whether the given text starts with a whitespace character.<p> 457 * 458 * @param text the text to check 459 * 460 * @return <code>true</code> if the text starts with whitespace 461 */ 462 private static boolean startsWithWhitespace(String text) { 463 464 return !text.isEmpty() && Character.isWhitespace(text.charAt(0)); 465 } 466 467 /** 468 * Wraps the given inline content with a Markdown marker, keeping leading and trailing 469 * whitespace outside the marker so the markup renders correctly.<p> 470 * 471 * @param content the inline content 472 * @param marker the marker to wrap with (for example "**") 473 * 474 * @return the wrapped content 475 */ 476 private static String wrap(String content, String marker) { 477 478 String core = content.trim(); 479 if (core.isEmpty()) { 480 return content; 481 } 482 int start = content.indexOf(core); 483 String leading = content.substring(0, start); 484 String trailing = content.substring(start + core.length()); 485 return leading + marker + core + marker + trailing; 486 } 487 488 /** 489 * Converts the given HTML fragment to Markdown.<p> 490 * 491 * @param html the HTML fragment to convert 492 * 493 * @return the Markdown representation of the given HTML fragment 494 */ 495 public String convert(String html) { 496 497 if (CmsStringUtil.isEmptyOrWhitespaceOnly(html)) { 498 return ""; 499 } 500 Document doc = Jsoup.parseBodyFragment(html); 501 StringBuilder result = new StringBuilder(); 502 for (String block : renderBlocks(doc.body())) { 503 // strip each block and drop blank ones, so a stray empty or trailing-newline block can 504 // never produce more than a single blank line between blocks (code block content, which 505 // starts and ends with a fence, is unaffected by the strip) 506 String trimmed = block.strip(); 507 if (!trimmed.isEmpty()) { 508 if (result.length() > 0) { 509 result.append("\n\n"); 510 } 511 result.append(trimmed); 512 } 513 } 514 return result.toString(); 515 } 516 517 /** 518 * Appends the inline rendering of a child node to the buffer, inserting a single separating space 519 * at the boundary between two structural or empty elements when none is present, so content that 520 * relies on CSS for spacing (for example list teaser tiles) does not run together. Inline markup 521 * (bold, italic, inline code, links, line breaks, images) keeps the source whitespace and is never 522 * force separated.<p> 523 * 524 * @param result the inline buffer 525 * @param previous the previously appended child node, or <code>null</code> 526 * @param child the child node to append 527 */ 528 private void appendInline(StringBuilder result, Node previous, Node child) { 529 530 String part = renderInline(child); 531 if ((result.length() > 0) 532 && (isSeparating(previous) || isSeparating(child)) 533 && !endsWithWhitespace(result) 534 && !startsWithWhitespace(part) 535 && !startsWithPunctuation(part)) { 536 result.append(' '); 537 } 538 result.append(part); 539 } 540 541 /** 542 * Renders a single block level element as a list of Markdown blocks.<p> 543 * 544 * @param element the block level element to render 545 * 546 * @return the list of Markdown blocks 547 */ 548 private List<String> renderBlock(Element element) { 549 550 String tag = element.tagName(); 551 if ((tag.length() == 2) && (tag.charAt(0) == 'h') && (tag.charAt(1) >= '1') && (tag.charAt(1) <= '6')) { 552 int level = tag.charAt(1) - '0'; 553 String text = renderInlineChildren(element).replace('\n', ' ').trim(); 554 return List.of("#".repeat(level) + " " + text); 555 } 556 switch (tag) { 557 case "hr": 558 return List.of("---"); 559 case "pre": 560 return List.of(renderPre(element)); 561 case "blockquote": 562 return List.of(renderBlockquote(element)); 563 case "ul": 564 return List.of(renderList(element, false, "").stripTrailing()); 565 case "ol": 566 return List.of(renderList(element, true, "").stripTrailing()); 567 default: 568 // p, div and other (pass through) block containers 569 return renderBlocks(element); 570 } 571 } 572 573 /** 574 * Renders a blockquote element, prefixing every produced line with the Markdown quote marker.<p> 575 * 576 * @param element the blockquote element 577 * 578 * @return the rendered blockquote block 579 */ 580 private String renderBlockquote(Element element) { 581 582 String inner = String.join("\n\n", renderBlocks(element)); 583 StringBuilder result = new StringBuilder(); 584 String[] lines = inner.split("\n", -1); 585 for (int i = 0; i < lines.length; i++) { 586 if (i > 0) { 587 result.append('\n'); 588 } 589 result.append(lines[i].isEmpty() ? ">" : "> " + lines[i]); 590 } 591 return result.toString(); 592 } 593 594 /** 595 * Renders the child nodes of the given element as a list of Markdown blocks.<p> 596 * 597 * Inline content is collected into paragraph blocks, block level children are rendered separately.<p> 598 * 599 * @param element the element whose children to render 600 * 601 * @return the list of Markdown blocks 602 */ 603 private List<String> renderBlocks(Element element) { 604 605 List<String> blocks = new ArrayList<String>(); 606 StringBuilder inline = new StringBuilder(); 607 Node previous = null; 608 for (Node node : element.childNodes()) { 609 if (isBlock(node)) { 610 flushInline(inline, blocks); 611 blocks.addAll(renderBlock((Element)node)); 612 previous = null; 613 } else { 614 appendInline(inline, previous, node); 615 previous = node; 616 } 617 } 618 flushInline(inline, blocks); 619 return blocks; 620 } 621 622 /** 623 * Renders an image element as a Markdown image.<p> 624 * 625 * @param element the image element 626 * 627 * @return the rendered Markdown image, or the empty String if there is no src 628 */ 629 private String renderImage(Element element) { 630 631 if (m_ignoreImages) { 632 return ""; 633 } 634 String src = element.attr("src").trim(); 635 if (src.isEmpty()) { 636 return ""; 637 } 638 String alt = element.attr("alt").trim(); 639 String title = element.attr("title").trim(); 640 String titlePart = title.isEmpty() ? "" : " \"" + title + "\""; 641 return ""; 642 } 643 644 /** 645 * Renders a single node as inline Markdown.<p> 646 * 647 * @param node the node to render 648 * 649 * @return the rendered inline Markdown 650 */ 651 private String renderInline(Node node) { 652 653 if (node instanceof TextNode) { 654 return escape(foldPunctuation(normalizeWhitespace(((TextNode)node).getWholeText()))); 655 } 656 if (!(node instanceof Element)) { 657 return ""; 658 } 659 Element element = (Element)node; 660 switch (element.tagName()) { 661 case "strong": 662 case "b": 663 return wrap(renderInlineChildren(element), "**"); 664 case "em": 665 case "i": 666 return wrap(renderInlineChildren(element), "*"); 667 case "code": 668 return renderInlineCode(element); 669 case "a": 670 return renderLink(element); 671 case "img": 672 return renderImage(element); 673 case "br": 674 return "\n"; 675 case "script": 676 case "style": 677 // non-content markup (for example ld+json structured data carrying an image 678 // copyright notice); drop the element and its text content entirely 679 return ""; 680 default: 681 // span and any other inline wrapper: render the children only 682 return renderInlineChildren(element); 683 } 684 } 685 686 /** 687 * Renders all child nodes of the given element as inline Markdown.<p> 688 * 689 * @param element the element whose children to render 690 * 691 * @return the rendered inline Markdown 692 */ 693 private String renderInlineChildren(Element element) { 694 695 StringBuilder result = new StringBuilder(); 696 Node previous = null; 697 for (Node node : element.childNodes()) { 698 appendInline(result, previous, node); 699 previous = node; 700 } 701 return result.toString(); 702 } 703 704 /** 705 * Renders an anchor element as a Markdown link.<p> 706 * 707 * @param element the anchor element 708 * 709 * @return the rendered Markdown link, or just the inline text if there is no href 710 */ 711 private String renderLink(Element element) { 712 713 String text = renderInlineChildren(element); 714 if (m_removeLinks) { 715 return text; 716 } 717 String href = element.attr("href").trim(); 718 if (href.isEmpty()) { 719 return text; 720 } 721 String core = text.trim(); 722 if (core.isEmpty()) { 723 core = href; 724 } 725 String title = element.attr("title").trim(); 726 String titlePart = title.isEmpty() ? "" : " \"" + title + "\""; 727 return "[" + core + "](" + href + titlePart + ")"; 728 } 729 730 /** 731 * Renders an ordered or unordered list, including nested lists.<p> 732 * 733 * @param list the list element (ul or ol) 734 * @param ordered if the list is ordered 735 * @param indent the indentation prefix for the items of this list 736 * 737 * @return the rendered list, terminated by a line break 738 */ 739 private String renderList(Element list, boolean ordered, String indent) { 740 741 StringBuilder result = new StringBuilder(); 742 int counter = 1; 743 for (Element item : list.children()) { 744 if (!"li".equals(item.tagName())) { 745 continue; 746 } 747 String marker = ordered ? (counter++) + ". " : "- "; 748 StringBuilder inline = new StringBuilder(); 749 List<Element> nested = new ArrayList<Element>(); 750 Node previous = null; 751 for (Node node : item.childNodes()) { 752 if ((node instanceof Element) && isList((Element)node)) { 753 nested.add((Element)node); 754 } else { 755 appendInline(inline, previous, node); 756 previous = node; 757 } 758 } 759 String text = inline.toString().replace('\n', ' ').trim(); 760 result.append(indent).append(marker).append(text).append('\n'); 761 String childIndent = indent + " ".repeat(marker.length()); 762 for (Element nestedList : nested) { 763 result.append(renderList(nestedList, "ol".equals(nestedList.tagName()), childIndent)); 764 } 765 } 766 return result.toString(); 767 } 768}