MarkdownRenderer.java

/*
 * Copyright © 2014-2025 The CTAN Team and individual authors
 *
 * This file is distributed under the 3-clause BSD license.
 * See file LICENSE for details.
 */

package org.ctan.markup.markdown;

import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.io.StringWriter;
import java.io.Writer;
import java.util.HashMap;
import java.util.Map;
import java.util.Stack;

import org.ctan.markup.Logos;
import org.ctan.markup.Tag;
import org.ctan.markup.markdown.syntax.HighlighterService;

/**
 * This class parses markdown and renders it as HTML.
 *
 * <p>
 * The generated HTML can have the following structures:
 * </p>
 * <ul>
 * <li>p</li>
 * <li>blockquote</li>
 * <li>pre</li>
 * <li>ul</li>
 * <li>ul ul</li>
 * <li>ul ol</li>
 * <li>ol</li>
 * <li>ol ol</li>
 * <li>ol ul</li>
 * <li></li>
 * </ul>
 *
 * @author <a href="mailto:gene@ctan.org">Gerd Neugebauer</a>
 */
public class MarkdownRenderer {

    /**
     * This enumeration provides the possible states for the parser.
     */
    private enum State {
        /**
         * The constant signals the end of a text.
         */
        CLOSED,
        /**
         * The constant signals the mid of a paragraph.
         */
        MID,
        /**
         * This constant indicates a newline.
         */
        NL,
        /**
         * This constant indicates a par.
         */
        PAR,
        /**
         * This constant indicates a space.
         */
        SPACE;

    }

    /**
     * The constant <code>BUFFER_SIZE</code> contains the size of the push-back
     * buffer.
     */
    private static final int BUFFER_SIZE = 1024;

    /**
     * The field <code>CELL_CENTER</code> contains the centered cell attribute.
     */
    private static final Map<String, String> CELL_CENTER =
                    initAttributes("align", "center");

    /**
     * The field <code>CELL_DEFAULT</code> contains the empty cell attributes.
     */
    private static final Map<String, String> CELL_DEFAULT =
                    new HashMap<String, String>();

    /**
     * The field <code>CELL_LEFT</code> contains the left aligned cell
     * attribute.
     */
    private static final Map<String, String> CELL_LEFT =
                    initAttributes("align", "left");

    /**
     * The field <code>CELL_RIGHT</code> contains the right aligned cell
     * attribute.
     */
    private static final Map<String, String> CELL_RIGHT =
                    initAttributes("align", "right");

    /**
     * This method creates a map of attributes with one key set to a value.
     *
     * @param key the key
     * @param value the value
     *
     * @return the new Map
     */
    private static Map<String, String> initAttributes(String key,
                    String value) {

        Map<String, String> map = new HashMap<String, String>();
        map.put(key, value);
        return map;
    }

    /**
     * The field <code>resolver</code> contains the mention resolver.
     */
    private MentionResolver resolver = null;

    /**
     * The field <code>in</code> contains the input reader.
     */
    private MarkdownScanner in;

    /**
     * The field <code>out</code> contains the output writer.
     */
    private MarkdownWriter out;

    /**
     * The field <code>outerArgs</code> contains the arguments for the outer
     * tag.
     */
    private Map<String, String> outerArgs;

    /**
     * The field <code>outerTag</code> contains the outer tag.
     */
    private Tag outerTag;

    /**
     * The field <code>sanitizer</code> contains the HTML sanitizer.
     */
    private HtmlSanitizer sanitizer = null;

    /**
     * The field <code>stack</code> contains the stack of open tags.
     */
    private Stack<Tag> stack = new Stack<Tag>();

    /**
     * The field <code>state</code> contains the current state.
     */
    private State state;

    /**
     * The field <code>urlPrefix</code> contains the prefix prepended before
     * relative URLs.
     */
    private String urlPrefix;

    /**
     * This is the constructor for <code>MarkdownRenderer</code>.
     *
     * @param content the content
     * @param outerTag the outer tag
     * @param outerArgs the arguments for the outer tag
     * @param urlPrefix the URL prefix
     */
    protected MarkdownRenderer(CharSequence content, Tag outerTag,
                    Map<String, String> outerArgs, String urlPrefix) {

        this.in = new MarkdownScanner(//
            new StringReader(content.toString()), BUFFER_SIZE);
        this.outerTag = outerTag;
        this.outerArgs = outerArgs;
        this.urlPrefix = urlPrefix;
    }

    /**
     * This is the constructor for <code>MarkdownRenderer</code>.
     *
     * @param reader the reader
     * @param urlPrefix the URL prefix
     */
    public MarkdownRenderer(Reader reader, String urlPrefix) {

        this(reader, Tag.P, null, urlPrefix);
    }

    /**
     * This is the constructor for <code>MarkdownRenderer</code>.
     *
     * @param reader the reader
     * @param outerTag the outer tag
     * @param outerArgs the arguments for the outer tag
     * @param urlPrefix the URL prefix
     */
    protected MarkdownRenderer(Reader reader, Tag outerTag,
                    Map<String, String> outerArgs, String urlPrefix) {

        this.in = new MarkdownScanner(reader, BUFFER_SIZE);
        this.outerTag = outerTag;
        this.outerArgs = outerArgs;
        this.urlPrefix = urlPrefix;
    }

    /**
     * This is the constructor for <code>MarkdownRenderer</code>. -
     *
     * @param content the content
     * @param urlPrefix the URL prefix
     */
    public MarkdownRenderer(String content, String urlPrefix) {

        this(new StringReader(content), urlPrefix);
    }

    /**
     * This is the constructor for <code>MarkdownRenderer</code>.
     *
     * @param content the content
     * @param tag the enclosing tag
     * @param attributes the attributes
     * @param urlPrefix the URL prefix
     */
    protected MarkdownRenderer(String content, Tag tag,
                    Map<String, String> attributes, String urlPrefix) {

        this(new StringReader(content), tag, attributes, urlPrefix);
    }

    /**
     * This method keeps track of the state when a newline has been encountered.
     *
     * @throws IOException in case of an I/O error
     */
    private void advanceStateOnNewline() throws IOException {

        if (state == State.NL) {
            state = State.PAR;
        } else if (state != State.PAR) {
            state = State.NL;
        }
        var c = in.lookahead();
        if (c < 0 || (!Character.isLetter(c))) {
            return;
        }
        var line = in.readLine();

        if (line.indexOf('|') > 0) {
            in.unget('\n', line);
            in.unget("|");
        } else if (in.expect('=', '=', '=')) {
            in.skipLine();
            in.unget('\n', line, "# ");
        } else if (in.expect('-', '-', '-')) {
            in.skipLine();
            in.unget('\n', line, "## ");
        } else {
            in.unget('\n', line);
        }
    }

    /**
     * This method writes a character after the surrounding tag has optionally
     * been initialised.
     *
     * @throws IOException in case of an I/O error
     */
    private void initLine() throws IOException {

        switch (state) {
            case PAR:
                popTo(outerTag);
                if (outerTag != null) {
                    outerTag.start(out, outerArgs);
                    stack.push(outerTag);
                }
                state = State.MID;
                break;
            case NL:
            case SPACE:
                out.write(' ');
                state = State.MID;
                break;
            default:
                break;
        }
    }

    /**
     * This method prepends the URL prefix before the URL is a relative URL is
     * passed in. Otherwise the given URL is left unchanged.
     *
     * @param url the URL to augment
     *
     * @return the completed URL
     */
    private String makeUrl(String url) {

        if (url.startsWith("/") //
                        || url.startsWith("https://") //
                        || url.startsWith("http://") //
                        || url.startsWith("mailto:") //
                        || url.startsWith("irc://") //
                        || url.startsWith("ftp://")) {
            return url;
        }
        return urlPrefix + url;
    }

    /**
     * This method pops tags from the stack until one of the given tags is
     * found. The end strings are written to the output writer.
     *
     * @param tags the list of terminating tags
     *
     * @return the tag found or {@code null}
     *
     * @throws IOException in case of an I/O error
     */
    private Tag popTo(Tag... tags) throws IOException {

        while (!stack.isEmpty()) {
            var tag = stack.pop();
            tag.end(out);
            for (Tag t : tags) {
                if (tag == t) {
                    return t;
                }
            }
        }
        return null;
    }

    /**
     * This method parses the input, renders it, and returns the result as
     * String.
     *
     * @return the rendered HTML
     *
     * @throws IOException in case of an I/O error
     */
    public String render() throws IOException {

        var writer = new StringWriter();
        render(writer);
        return writer.toString();
    }

    /**
     * This method parses the input and renders it to the given writer.
     * Whitespace at the beginning is ignored.
     *
     * @param writer the writer for output
     *
     * @throws IOException in case of an I/O error
     */
    public void render(Writer writer) throws IOException {

        out = new MarkdownWriter(writer);
        try {
            renderLines();
        } finally {
            state = State.CLOSED;
            in.close();
            out.close();
        }
    }

    /**
     * This method parses and renders a block quote.
     *
     * @throws IOException in case of an I/O error
     */
    private void renderBlockquote() throws IOException {

        var buffer = new StringBuilder();
        for (var c = in.read(); c >= 0; c = in.read()) {
            if (c == '\n') {
                if (!in.expect('>', ' ')) {
                    break;
                }
                var i = buffer.length() - 6;
                if (i > 0 && !"<br />".equals(buffer.substring(i))) {
                    buffer.append("<br />");
                }
            }
            buffer.append((char) c);
        }
        popTo();
        Tag.BLOCKQUOTE.start(out);
        new MarkdownRenderer(buffer.toString().replaceFirst("<br />\n$", ""),
            Tag._SKIP, null, urlPrefix).render(out);
        Tag.BLOCKQUOTE.end(out);
        state = State.PAR;
    }

    /**
     * This method parses and renders a pre-formatted block until the
     * terminating ``` are encountered.
     *
     * @param c the character
     *
     * @throws IOException in case of an I/O error
     */
    private void renderCode(char c) throws IOException {

        var type = in.readLine().replaceAll("[<> \t&%]", "").toLowerCase();
        popTo();
        Map<String, String> map = new HashMap<String, String>();
        if (!"".equals(type)) {
            map.put("class", "language-" + type);
            // } else {
            // map.put("class", "markdown");
        }
        Tag.PRE.start(out);
        Tag.CODE.start(out, map);
        out.write('\n');
        HighlighterService.getService().highlight(type, in, out);
        Tag.CODE.end(out);
        Tag.PRE.end(out);
        out.write('\n');
    }

    /**
     * This method renders a code block indented by four spaces.
     *
     * @param spaces the indentation
     *
     * @throws IOException in case of an I/O error
     */
    private void renderCodeBlock(int spaces) throws IOException {

        popTo();
        Tag.PRE.start(out);
        Tag.CODE.start(out);
        for (var i = 4; i < spaces; i++) {
            out.write(' ');
        }
        for (var c = in.read(); c >= 0
                        && !(c == '\n' && !in.expect(' ', ' ', ' ', ' ')
                                        && !in.expect('\t')); c = in.read()) {
            out.writeEscaped(c);
        }
        Tag.CODE.end(out);
        Tag.PRE.end(out);
        out.write('\n');
    }

    /**
     * This method renders in-line pre-formatted code.
     *
     * @throws IOException in case of an I/O error
     */
    private void renderCodeInline() throws IOException {

        initLine();
        Tag.CODE.start(out);
        for (var c = in.read(); c >= 0 && c != '\n' && c != '`'; c =
                        in.read()) {
            out.writeEscaped(c);
        }
        Tag.CODE.end(out);
    }

    /**
     * This method parses and renders a HTML entity.
     *
     * @throws IOException in case of an I/O error
     */
    private void renderEntity() throws IOException {

        var buffer = new StringBuilder();
        var c = in.read();
        if (c == '#') {
            buffer.append((char) c);
        } else {
            in.unget(c);
        }
        var n = 10;
        for (c = in.read(); c != ';'; c = in.read()) {
            if (--n < 1 || c < 0 || !Character.isLetterOrDigit(c)) {
                in.unget(c, buffer);
                write("&amp;");
                return;
            }
            buffer.append((char) c);
        }

        var s = buffer.toString();
        if ("#58".equals(s)) {
            write(":");
        } else {
            write("&", s, ";");
        }
    }

    /**
     * This method parses and renders an enumeration.
     *
     * @param num the parameter
     *
     * @throws IOException in case of an I/O error
     */
    private void renderEnumeration(String num) throws IOException {

        if (outerTag == Tag.P) {
            popTo();
        }
        if ("1".equals(num)) {
            Tag.OL.start(out);
        } else {
            Tag.OL.start(out, "start", num);
        }
        var buffer = new StringBuilder();
        for (var c = in.read(); c >= 0; c = in.read()) {
            if (c == '\n') {
                if (in.expectNumberPeriod(-1) != null) {
                    new MarkdownRenderer(buffer, Tag.LI, null, urlPrefix)
                        .render(out);
                    buffer = new StringBuilder();
                } else if (!in.expect(' ', ' ') && !in.expect('\t')) {
                    break;
                }
            }
            buffer.append((char) c);
        }
        new MarkdownRenderer(buffer, Tag.LI, null, urlPrefix).render(out);
        Tag.OL.end(out);
    }

    /**
     * This method renders a horizontal rule.
     *
     * @throws IOException in case of an I/O error
     */
    private void renderHorizontalRule() throws IOException {

        popTo();
        Tag.HR.write(out, null);
        state = State.PAR;
    }

    /**
     * This method parses and renders an image.
     *
     * @throws IOException in case of an I/O error
     */
    private void renderImage() throws IOException {

        if (!in.expect('[')) {
            out.write("!");
            return;
        }
        var text = in.readBrackets();
        if (text == null) {
            out.write("![");
            return;
        }
        if (!in.expect('(')) {
            out.write("![");
            in.unget(']', text);
            return;
        }

        var pp = in.expectParens();

        if (pp == null) {
            out.write("![");
            in.unget(']', text);
            return;
        }
        Map<String, String> attr = new HashMap<String, String>();
        attr.put("alt", text);
        attr.put("src", pp[0]);
        if (pp[1] != null) {
            attr.put("title", pp[1]);
        }
        Tag.IMG.write(out, attr);
    }

    /**
     * This method parses and renders an itemize list.
     *
     * @throws IOException in case of an I/O error
     */
    private void renderItemize() throws IOException {

        if (outerTag == Tag.P) {
            popTo();
        }
        Tag.UL.start(out);
        var buffer = new StringBuilder();
        for (var c = in.read(); c >= 0; c = in.read()) {
            if (c == '\n') {
                if (in.expect('+', ' ') //
                                || in.expect('-', ' ') //
                                || in.expect('*', ' ') //
                                || in.expect(' ', '+', ' ') //
                                || in.expect(' ', '-', ' ') //
                                || in.expect(' ', '*', ' ')) {
                    new MarkdownRenderer(buffer, Tag.LI, null, urlPrefix)
                        .render(out);
                    buffer = new StringBuilder();
                } else if (!in.expect(' ', ' ') && !in.expect('\t')) {
                    break;
                }
            }
            buffer.append((char) c);
        }
        new MarkdownRenderer(buffer, Tag.LI, null, urlPrefix).render(out);
        Tag.UL.end(out);
    }

    /**
     * This method parses the input reader till its end and renders the contents
     * to HTML.
     *
     * @throws IOException in case of an I/O error
     */
    private void renderLines() throws IOException {

        state = State.NL;
        renderLineStart(in.skipWhiteSpace(0));

        for (var c = in.read(); c >= 0; c = in.read()) {
            switch (c) {
                case '\\':
                    c = in.read();
                    switch (c) {
                        case '\\':
                        case '`':
                        case '*':
                        case '_':
                        case '{':
                        case '}':
                        case '[':
                        case ']':
                        case '(':
                        case ')':
                        case '#':
                        case '+':
                        case '-':
                        case '!':
                        case '.':
                            write(c);
                            break;
                        case '\n':
                            if (outerTag != null && outerTag.isSection()) {
                                break;
                            } else {
                                if (state == State.MID
                                                || state == State.SPACE) {
                                    write("<br>\n");
                                    break;
                                }
                            }
                            in.unget(c);
                            break;
                        default:
                            in.unget(c);
                    }
                    continue;
                case '\0':
                    write('�');
                    continue;
                case ' ':
                case '\t':
                case '\f':
                case '\b':
                    if (state == State.MID) {
                        state = State.SPACE;
                    }
                    continue;
                case '\n':
                    renderLineStart(in.skipWhiteSpace(0)); // TODO
                    continue;
                case '*':
                case '_':
                    if (in.expect(c, c)) {
                        toggleStyle(Tag.B_I);
                    } else {
                        toggleStyle(in.expect(c) ? Tag.B : Tag.EM);
                    }
                    continue;
                case '~':
                    if (in.expect(c)) {
                        toggleStyle(Tag.STRIKE);
                        continue;
                    }
                    break;
                case '-':
                    if (in.expect(c)) {
                        write(in.expect(c) ? "&mdash;" : "&ndash;");
                    } else {
                        write(c);
                    }
                    continue;
                case '[':
                    if (renderLink()) {
                        continue;
                    }
                    break;
                case '>':
                    write("&gt;");
                    continue;
                case '<':
                    var url = in.expectUrl('>', true);
                    if (url != null) {
                        in.read();
                        renderLink(url, url, null);
                        continue;
                    }
                    in.unget(c);
                    if (state == State.SPACE) {
                        out.write(' ');
                    }
                    sanitizeHtml();
                    continue;
                case '&':
                    renderEntity();
                    continue;
                case '!':
                    renderImage();
                    continue;
                case '@':
                    if (state != State.MID && resolver != null) {
                        var user = in.expectUrl(':', false);
                        if (user != null) {
                            var href = resolver.resolve(user);
                            if (href != null) {
                                write(c);
                                renderLink(href, user, null);
                                continue;
                            }
                            in.unget(user);
                        }
                    }
                    break;
                case '`':
                    renderCodeInline();
                    continue;
                case 'B':
                    if (in.expect('i', 'b', 'T', 'e', 'X')) {
                        write(Logos.BIBTEX);
                        continue;
                    }
                    break;
                case '(':
                    if (in.expect('L', 'a', ')', 'T', 'e', 'X')) {
                        write(Logos._LA_TEX);
                        continue;
                    }
                    break;
                case 'L':
                    if (in.expect('a', 'T', 'e', 'X')) {
                        if (in.expect('e') || in.expect('2', 'e')) {
                            write(Logos.LATEX2E);
                        } else if (in.expect('(', '2', 'e', ')')) {
                            write(Logos.LATEX_2E_);
                        } else {
                            write(Logos.LATEX);
                        }
                        continue;
                    }
                    break;
                case 'M':
                    if (in.expect('e', 't', 'a')) {
                        if (in.expect('f', 'o', 'n', 't')
                                        || in.expect('F', 'o', 'n', 't')) {
                            write(Logos.METAFONT);
                        } else if (in.expect('p', 'o', 's', 't')
                                        || in.expect('P', 'o', 's', 't')) {
                            write(Logos.METAPOST);
                        } else {
                            write("Meta");
                        }
                        continue;
                    }
                    break;
                case 'T':
                    if (in.expect('e', 'X')) {
                        write(Logos.TEX);
                        continue;
                    }
                    break;
                case 'X':
                    if (in.expect('e', 'T', 'e', 'X')) {
                        write(Logos.XETEX);
                        continue;
                    } else if (in.expect('e', '(', 'L', 'a', ')', 'T', 'e',
                        'X')) {
                        write(Logos.XE_LA_TEX);
                        continue;
                    } else if (in.expect('e', 'L', 'a', 'T', 'e', 'X')) {
                        write(Logos.XELATEX);
                        continue;
                    }
                    break;
                case 'e':
                    if (in.expect('-', 'T', 'e', 'X')) {
                        write(Logos.ETEX);
                        continue;
                    }
                    break;
                case 'f':
                    if (in.expect('t', 'p', ':', '/', '/')) {
                        renderLink("ftp://");
                        continue;
                    }
                    break;
                case 'h':
                    if (in.expect('t', 't', 'p', ':', '/', '/')) {
                        renderLink("http://");
                        continue;
                    } else if (in.expect('t', 't', 'p', 's', ':', '/', '/')) {
                        renderLink("https://");
                        continue;
                    }
                    break;
                case 'i':
                    if (in.expect('r', 'c', ':', '/', '/')) {
                        renderLink("irc://");
                        continue;
                    }
                    break;
                case 'm':
                    if (in.expect('a', 'i', 'l', 't', 'o', ':')) {
                        renderLink("mailto:");
                        continue;
                    }
                    break;
                default:
                    // nothing to do
            }
            write(c);
        }
        popTo();
    }

    /**
     * This method processes the beginning of a line. It is assumed that a
     * newline has been digested recently.
     *
     * @param spaces the number of preceding spaces
     *
     * @throws IOException in case of an I/O error
     */
    private void renderLineStart(int spaces) throws IOException {

        advanceStateOnNewline();
        if (state == State.PAR && spaces >= 4) {
            renderCodeBlock(spaces);
            return;
        }

        for (var c = in.read(); c >= 0; c = in.read()) {
            switch (c) {
                case ' ':
                    if (state == State.PAR && in.expect(' ', ' ', ' ')) {
                        renderCodeBlock(4);
                    }
                    continue;
                case '\t':
                    renderCodeBlock(0);
                    continue;
                case '\n':
                    advanceStateOnNewline();
                    spaces = 0;
                    continue;
                case '#':
                    if (spaces <= 3 && renderSection()) {
                        return;
                    }
                    continue;
                case '_':
                    if (in.expectLineWith(c)) {
                        renderHorizontalRule();
                        continue;
                    }
                    break;
                case '-':
                case '*':
                    if (in.expectLineWith(c)) {
                        renderHorizontalRule();
                        continue;
                    } else if (in.expect(' ')) {
                        renderItemize();
                        return;
                    }
                    break;
                case '+':
                    if (in.expect(' ')) {
                        renderItemize();
                        return;
                    }
                    break;
                case '|':
                    renderTable();
                    return;
                case '>':
                    renderBlockquote();
                    return;
                case '`':
                    if (in.expect('`', '`')) {
                        renderCode('`');
                        return;
                    }
                    break;
                case '~':
                    if (in.expect('~', '~')) {
                        renderCode('~');
                        return;
                    }
                    break;
                case '0':
                case '1':
                case '2':
                case '3':
                case '4':
                case '5':
                case '6':
                case '7':
                case '8':
                case '9':
                    var num = in.expectNumberPeriod(c);
                    if (num != null) {
                        renderEnumeration(num);
                        return;
                    }
                    break;
                default:
                    break;
            }
            in.unget(c);
            return;
        }
    }

    /**
     * This method parses and renders a link. The following patterns are
     * supported:
     *
     * <pre>
     *  [ALT](URL)
     *  [ALT](URL "TITLE")
     *  [ALT](URL 'TITLE')
     *  [ALT][REFERENCE]
     *  [REFERENCE]: URL
     *  [REFERENCE]: URL "TITLE"
     *  [REFERENCE]: URL 'TITLE'
     *  [REFERENCE]
     * </pre>
     *
     * @throws IOException in case of an I/O error
     */
    private boolean renderLink() throws IOException {

        var text = in.readBrackets();
        if (text == null) {
            return false;
        }

        var space = '\0';
        int c;

        for (c = in.read(); c >= 0 && Character.isWhitespace(c); c =
                        in.read()) {
            space = (char) c;
        }
        switch (c) {
            case ':':
                var url = in.expectUrl(')', false);
                if (url == null) {
                    in.unget(c);
                    renderLink(text, text, null);
                    return false;
                }
                var title = in.expectString();
                out.defineReference(text, url, title != null ? title : "");
                break;
            case '[':
                var mark = in.readBrackets();
                if (mark == null) {
                    in.unget(c);
                    renderLink(text, text, null);
                } else {
                    renderReference(mark, text);
                }
                break;
            case '(':
                var pp = in.expectParens();
                if (pp == null) {
                    renderLink(text, text, null);
                } else {
                    renderLink(pp[0], text, pp[1]);
                }
                break;
            default:
                if (c >= 0) {
                    in.unget(c);
                }
                if (space > 0) {
                    in.unget(space);
                }
                renderLink(text, text, null);
        }
        return true;
    }

    /**
     * This method parses and renders a URL as link.
     *
     * @param protocol the protocol already read
     *
     * @throws IOException in case of an I/O error
     */
    private void renderLink(String protocol) throws IOException {

        var url =
                        in.readToWhitespace(new StringBuilder(protocol))
                            .toString();
        if (url.matches(".*[.,:;?!]$")) {
            var idx = url.length() - 1;
            in.unget(url.charAt(idx));
            url = url.substring(0, idx);
        }
        renderLink(url, url, null);
    }

    /**
     * This method renders a link. The URL might be missing. In this case the
     * text is rendered in a span with class <code>no-link</code>.
     *
     * @param href the URL or {@code null}
     * @param text the text
     * @param title the title or {@code null} for none
     *
     * @throws IOException in case if an I/O error
     */
    private void renderLink(String href, String text, String title)
                    throws IOException {

        Map<String, String> attr = new HashMap<String, String>();
        initLine();
        Tag tag;
        if (href == null) {
            attr.put("class", "no-link");
            tag = Tag.SPAN;
        } else {
            attr.put("href", makeUrl(href));
            attr.put("title", title);
            tag = Tag.A;
        }
        tag.start(out, attr);
        new MarkdownRenderer(text.replaceFirst(":", "&#58;"), Tag._SKIP, null,
            urlPrefix).render(out);
        tag.end(out);
    }

    /**
     * This method renders a reference. A reference is a deferred link defined
     * somewhere else.
     *
     * @param ref the key of the reference
     * @param text the link text
     *
     * @throws IOException in case if an I/O error
     */
    private void renderReference(String ref, String text) throws IOException {

        initLine();
        out.write("<a href=\"");
        out.writeReference(ref);
        out.write("\" title=\"");
        out.writeReferenceText(ref);
        out.write("\">");

        new MarkdownRenderer(text.replaceFirst(":", "&#58;"), Tag._SKIP, null,
            urlPrefix).render(out);
        Tag.A.end(out);
    }

    /**
     * This method parses and renders a section.
     *
     * @throws IOException in case of an I/O error
     */
    private boolean renderSection() throws IOException {

        popTo();

        var level = in.scanSectionDepth(Tag.SECTION.size() - 1);
        if (level < 0) {
            return true;
        }
        // in.expect(' ');
        var line = in.readLine().replaceAll(" +#* *$", "");
        var tag = Tag.SECTION.get(level);
        if (line.isBlank()) {
            tag.start(out);
            tag.end(out);
        } else {
            new MarkdownRenderer(line, tag, null, urlPrefix)
                .render(out);
        }
        state = State.NL;
        advanceStateOnNewline();
        return false;
    }

    /**
     * This method parses and renders a table.
     *
     * @throws IOException in case of an I/O error
     */
    private void renderTable() throws IOException {

        in.expect('\n');
        var line = in.readLine();
        var format = in.readLine();
        var fmt = tableFormat(
            format.replaceFirst("^[|]", "").split("\\s*[|]\\s*"));
        if (fmt == null) {
            in.unget('\n', format);
            in.unget('\n', line);
            return;
        }
        popTo();
        Tag.TABLE.start(out);
        Tag.THEAD.start(out);
        tableRender(fmt, Tag.TH, line, true);
        Tag.THEAD.end(out);

        var body = false;
        for (line = in.readLine(); line.indexOf('|') >= 0; line =
                        in.readLine()) {
            body = tableRender(fmt, Tag.TD, line, body);
        }

        in.unget('\n', line);
        if (body) {
            Tag.TBODY.end(out);
        }
        Tag.TABLE.end(out);
        state = State.PAR;
    }

    /**
     * This method scans for embedded HTML and passes on only allowed tags and
     * attributes.
     *
     * @throws IOException in case of an I/O error
     */
    private void sanitizeHtml() throws IOException {

        if (sanitizer == null) {
            sanitizer = new HtmlSanitizer(in, out);
        }
        if (!sanitizer.sanitize()) {
            initLine();
            out.write("&lt;");
        }
    }

    /**
     * This is the setter for <code>resolver</code>.
     *
     * @param resolver the new value for resolver
     *
     * @return this
     */
    public MarkdownRenderer setMentionResolver(MentionResolver resolver) {

        this.resolver = resolver;
        return this;
    }

    /**
     * This method is the setter for the URL prefix.
     *
     * @param urlPrefix the new URL prefix
     */
    public void setUrlPrefix(String urlPrefix) {

        this.urlPrefix = urlPrefix == null ? "" : urlPrefix;
    }

    /**
     * This method parses a table format line and translates it into an array of
     * attributes for the columns.
     *
     * @param format the format strings to translate
     *
     * @return an array of maps containing the attributes or {@code null} if no
     *     proper format has been found
     */
    private Map<String, String>[] tableFormat(String[] format) {

        @SuppressWarnings("unchecked")
        Map<String, String>[] fmt = new Map[format.length];
        var i = 0;
        for (String f : format) {
            f = f.trim();
            if (!f.matches(":?-+:?")) {
                return null;
            } else if (f.startsWith(":")) {
                if (f.endsWith(":")) {
                    fmt[i++] = CELL_CENTER;
                } else {
                    fmt[i++] = CELL_LEFT;
                }
            } else if (f.endsWith(":")) {
                fmt[i++] = CELL_RIGHT;
            } else {
                fmt[i++] = CELL_DEFAULT;
            }
        }
        return fmt;
    }

    /**
     * This method a table row.
     *
     * @param fmt the formats for the cells
     * @param dataTag the table data tag; i.e. TD or TH
     * @param cells the array of cells
     * @param line the row specification
     * @param body indicator for the body
     *
     * @throws IOException in case of an I/O error
     */
    private boolean tableRender(Map<String, String>[] fmt, Tag dataTag,
                    String line, boolean body)
                    throws IOException {

        if (!body) {
            Tag.TBODY.start(out);
        }
        Tag.TR.start(out);
        var cells = line.replaceFirst("^[|]", "").split("\\s*[|]\\s*");
        var i = 0;
        for (String cell : cells) {
            new MarkdownRenderer(cell, dataTag,
                i < fmt.length ? fmt[i] : CELL_DEFAULT, urlPrefix).render(out);
            i++;
        }
        Tag.TR.end(out);
        return true;
    }

    /**
     * This method toggles the B, I or B_I style.
     *
     * @param tag the style tag
     *
     * @throws IOException in case of an I/O error
     */
    private void toggleStyle(Tag tag) throws IOException {

        if (stack.contains(tag)) {
            popTo(tag);
        } else if (tag == Tag.B_I && stack.contains(Tag.B)
                        && stack.contains(Tag.I)) {
            popTo(Tag.B, Tag.I);
            popTo(Tag.B, Tag.I);
        } else if (stack.contains(Tag.B_I)) {
            popTo(Tag.B_I);
            tag = (tag == Tag.B ? Tag.I : Tag.B);
            initLine();
            tag.start(out);
            stack.push(tag);
        } else {
            initLine();
            tag.start(out);
            stack.push(tag);
        }
    }

    /**
     * {@inheritDoc}
     *
     * @see java.lang.Object#toString()
     */
    @Override
    public String toString() {

        return state.toString();
    }

    /**
     * This method writes out a single character.
     *
     * @param c the character code of the character to write
     *
     * @throws IOException in case of an I/O error
     */
    private void write(int c) throws IOException {

        initLine();
        out.write(c);
    }

    /**
     * This method writes out a string.
     *
     * @param strings the strings to be written
     *
     * @throws IOException in case of an I/O error
     */
    private void write(String... strings) throws IOException {

        initLine();
        for (String s : strings) {
            out.write(s);
        }
    }

}