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("&");
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) ? "—" : "–");
} else {
write(c);
}
continue;
case '[':
if (renderLink()) {
continue;
}
break;
case '>':
write(">");
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(":", ":"), 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(":", ":"), 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("<");
}
}
/**
* 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);
}
}
}