/* * Copyright (C) 2011 René Jeschke <rene_jeschke@yahoo.de> * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package io.kaif.kmark; import java.util.ArrayList; import java.util.LinkedHashMap; import com.google.common.base.Strings; import io.kaif.model.account.Account; /** * Emitter class responsible for generating HTML output. * * @author René Jeschke <rene_jeschke@yahoo.de> */ class Emitter { /** * Turns every whitespace character into a space character. * * @param c * Character to check * @return 32 is c was a whitespace, c otherwise */ private static char whitespaceToSpace(char c) { return Character.isWhitespace(c) ? ' ' : c; } /** * Link references. */ private final LinkedHashMap<String, LinkRef> linkRefs = new LinkedHashMap<>(); /** * The configuration. */ private final Configuration config; /** * Constructor. */ public Emitter(final Configuration config) { this.config = config; } /** * Adds a LinkRef to this set of LinkRefs. * * @param key * The key/id. */ public LinkRef addLinkRef(final String key, final String link, final String title) { final String lowerCase = key.toLowerCase(); final LinkRef linkRef; if (this.linkRefs.containsKey(lowerCase)) { linkRef = new LinkRef(this.linkRefs.get(lowerCase).seqNumber, link, title); } else { linkRef = new LinkRef(this.linkRefs.size() + 1, link, title); } this.linkRefs.put(lowerCase, linkRef); return linkRef; } /** * Transforms the given block recursively into HTML. * * @param out * The StringBuilder to write to. * @param root * The Block to process. */ public void emit(final HtmlEscapeStringBuilder out, final Block root) { root.removeSurroundingEmptyLines(); switch (root.type) { case NONE: break; case PARAGRAPH: this.config.decorator.openParagraph(out); break; case BLOCKQUOTE: this.config.decorator.openBlockquote(out); break; case FENCED_CODE: if (this.config.codeBlockEmitter == null) { this.config.decorator.openCodeBlock(out); } break; case UNORDERED_LIST: this.config.decorator.openUnorderedList(out); break; case ORDERED_LIST: this.config.decorator.openOrderedList(out); break; case LIST_ITEM: this.config.decorator.openListItem(out); out.appendHtml('>'); break; } if (root.hasLines()) { this.emitLines(out, root); } else { Block block = root.blocks; while (block != null) { this.emit(out, block); block = block.next; } } switch (root.type) { case NONE: break; case PARAGRAPH: this.config.decorator.closeParagraph(out); break; case BLOCKQUOTE: this.config.decorator.closeBlockquote(out); break; case FENCED_CODE: if (this.config.codeBlockEmitter == null) { this.config.decorator.closeCodeBlock(out); } break; case UNORDERED_LIST: this.config.decorator.closeUnorderedList(out); break; case ORDERED_LIST: this.config.decorator.closeOrderedList(out); break; case LIST_ITEM: this.config.decorator.closeListItem(out); break; } } /** * Transforms lines into HTML. * * @param out * The StringBuilder to write to. * @param block * The Block to process. */ private void emitLines(final HtmlEscapeStringBuilder out, final Block block) { switch (block.type) { case FENCED_CODE: this.emitCodeLines(out, block.lines, block.meta); break; case PARAGRAPH: this.emitMarkedLines(out, block.lines); break; default: this.emitMarkedLines(out, block.lines); break; } } /** * Finds the position of the given Token in the given String. * * @param in * The String to search on. * @param start * The starting character position. * @param token * The token to find. * @return The position of the token or -1 if none could be found. */ private int findToken(final String in, int start, MarkToken token) { int pos = start; while (pos < in.length()) { if (this.getToken(in, pos) == token) { return pos; } pos++; } return -1; } /** * Checks if there is a valid markdown link definition. * * @param out * The StringBuilder containing the generated output. * @param in * Input String. * @param start * Starting position. * @return The new position or -1 if there is no valid markdown link. */ private int checkLink(final HtmlEscapeStringBuilder out, final String in, int start) { int pos = start + 1; final StringBuilder temp = new StringBuilder(); temp.setLength(0); pos = Utils.readMdLinkId(temp, in, pos); if (pos < start) { return -1; } String name = temp.toString(); LinkRef lr; final int oldPos = pos++; pos = Utils.skipSpaces(in, pos); if (pos < start) { lr = this.linkRefs.get(name.toLowerCase()); if (lr != null) { pos = oldPos; } else { return -1; } } else if (in.charAt(pos) == '[') { pos++; temp.setLength(0); pos = Utils.readRawUntil(temp, in, pos, ']'); if (pos < start) { return -1; } final String id = temp.length() > 0 ? temp.toString() : name; lr = this.linkRefs.get(id.toLowerCase()); } else { lr = this.linkRefs.get(name.toLowerCase()); if (lr != null) { pos = oldPos; } else { return -1; } } if (lr == null) { return -1; } if (lr.hasHttpScheme()) { this.config.decorator.openLink(out); out.appendHtml(" href=\"").append(lr.link).appendHtml("\""); if (!Strings.isNullOrEmpty(lr.title)) { out.appendHtml(" title=\"").append(lr.title).appendHtml("\""); } out.appendHtml(" class=\"reference-link\" rel=\"nofollow\" target=\"_blank\">"); this.recursiveEmitLine(out, name, 0, MarkToken.LINK); out.appendHtml("</a>"); } else { out.appendHtml("<span class=\"reference-broken-link\">"); this.recursiveEmitLine(out, name, 0, MarkToken.LINK); out.appendHtml("</span>"); } out.appendHtml("<span class=\"reference-link-index\">") .append(lr.seqNumber) .appendHtml("</span>"); return pos; } /** * Recursively scans through the given line, taking care of any markdown * stuff. * * @param out * The StringBuilder to write to. * @param in * Input String. * @param start * Start position. * @param token * The matching Token (for e.g. '*') * @return The position of the matching Token or -1 if token was NONE or no * Token could be found. */ private int recursiveEmitLine(final HtmlEscapeStringBuilder out, final String in, int start, MarkToken token) { int pos = start, a, b; final HtmlEscapeStringBuilder temp = new HtmlEscapeStringBuilder(); while (pos < in.length()) { final MarkToken mt = this.getToken(in, pos); if (token != MarkToken.NONE && (mt == token || token == MarkToken.EM_STAR && mt == MarkToken.STRONG_STAR || token == MarkToken.EM_UNDERSCORE && mt == MarkToken.STRONG_UNDERSCORE)) { return pos; } switch (mt) { case LINK: temp.reset(); b = this.checkLink(temp, in, pos); if (b > 0) { out.appendHtml(temp); pos = b; } else { out.appendHtml(in.charAt(pos)); } break; case EM_STAR: case EM_UNDERSCORE: temp.reset(); b = this.recursiveEmitLine(temp, in, pos + 1, mt); if (b > 0) { this.config.decorator.openEmphasis(out); out.appendHtml(temp); this.config.decorator.closeEmphasis(out); pos = b; } else { out.appendHtml(in.charAt(pos)); } break; case STRONG_STAR: case STRONG_UNDERSCORE: temp.reset(); b = this.recursiveEmitLine(temp, in, pos + 2, mt); if (b > 0) { this.config.decorator.openStrong(out); out.appendHtml(temp); this.config.decorator.closeStrong(out); pos = b + 1; } else { out.appendHtml(in.charAt(pos)); } break; case STRIKE: temp.reset(); b = this.recursiveEmitLine(temp, in, pos + 2, mt); if (b > 0) { this.config.decorator.openStrike(out); out.appendHtml(temp); this.config.decorator.closeStrike(out); pos = b + 1; } else { out.appendHtml(in.charAt(pos)); } break; case SUPER: temp.reset(); b = this.recursiveEmitLine(temp, in, pos + 1, mt); if (b > 0) { this.config.decorator.openSuper(out); out.appendHtml(temp); this.config.decorator.closeSuper(out); pos = b; } else { out.appendHtml(in.charAt(pos)); } break; case CODE_SINGLE: case CODE_DOUBLE: a = pos + (mt == MarkToken.CODE_DOUBLE ? 2 : 1); b = this.findToken(in, a, mt); if (b > 0) { pos = b + (mt == MarkToken.CODE_DOUBLE ? 1 : 0); while (a < b && in.charAt(a) == ' ') { a++; } if (a < b) { while (in.charAt(b - 1) == ' ') { b--; } this.config.decorator.openCodeSpan(out); out.appendHtml(in.substring(a, b)); this.config.decorator.closeCodeSpan(out); } } else { out.appendHtml(in.charAt(pos)); } break; case USER: if (token == MarkToken.LINK) { out.appendHtml(in.charAt(pos)); } else { temp.reset(); b = this.checkUserLink(temp, in, pos); if (b > 0) { out.appendHtml(temp); pos = b; } else { out.appendHtml(in.charAt(pos)); } } break; case ZONE: if (token == MarkToken.LINK) { out.appendHtml(in.charAt(pos)); } else { temp.reset(); b = this.checkZoneLink(temp, in, pos); if (b > 0) { out.appendHtml(temp); pos = b; } else { out.appendHtml(in.charAt(pos)); } } break; case ESCAPE: pos++; //$FALL-THROUGH$ default: out.appendHtml(in.charAt(pos)); break; } pos++; } return -1; } /** * change this should review Account.java * * @param out * @param in * @param start * @return */ private int checkUserLink(HtmlEscapeStringBuilder out, String in, int start) { int pos = start + 3; // skip /u/ StringBuilder temp = new StringBuilder(); String targetString = in.substring(pos, Math.min(in.length(), pos + Account.NAME_MAX)); for (int i = 0; i < targetString.length(); i++) { char c = targetString.charAt(i); if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') { temp.append(c); } else { break; } } String username = temp.toString(); if (username.length() < Account.NAME_MIN || username.equalsIgnoreCase("null")) { return -1; } this.config.decorator.openLink(out); out.appendHtml(" href=\"") .append("/u/") .append(username) .appendHtml("\" class=\"user-link\">") .append("/u/") .append(username); out.appendHtml("</a>"); return pos + username.length() - 1; } /** * change this should review Zone.java * * @param out * @param in * @param start * @return */ private int checkZoneLink(HtmlEscapeStringBuilder out, String in, int start) { int pos = start + 3; // skip /z/ StringBuilder temp = new StringBuilder(); String targetString = in.substring(pos, Math.min(in.length(), pos + 20)); boolean prevIsDash = false; for (int i = 0; i < targetString.length(); i++) { char c = targetString.charAt(i); if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) { temp.append(c); prevIsDash = false; } else if (c == '-') { if (i == 0) { return -1; //start with - } if (prevIsDash) { temp.deleteCharAt(temp.length() - 1); //remove last dash break; } temp.append(c); prevIsDash = true; } else { if (prevIsDash) { temp.deleteCharAt(temp.length() - 1); //remove last dash } break; } } String zone = temp.toString(); if (zone.length() < 3 || zone.equalsIgnoreCase("null")) { return -1; } this.config.decorator.openLink(out); out.appendHtml(" href=\"") .append("/z/") .append(zone) .appendHtml("\" class=\"zone-link\">") .append("/z/") .append(zone); out.appendHtml("</a>"); return pos + zone.length() - 1; } /** * Check if there is any markdown Token. * * @param in * Input String. * @param pos * Starting position. * @return The Token. */ private MarkToken getToken(final String in, final int pos) { final char c0 = pos > 0 ? whitespaceToSpace(in.charAt(pos - 1)) : ' '; final char c = whitespaceToSpace(in.charAt(pos)); final char c1 = pos + 1 < in.length() ? whitespaceToSpace(in.charAt(pos + 1)) : ' '; final char c2 = pos + 2 < in.length() ? whitespaceToSpace(in.charAt(pos + 2)) : ' '; switch (c) { case '*': if (c1 == '*') { return c0 != ' ' || c2 != ' ' ? MarkToken.STRONG_STAR : MarkToken.EM_STAR; } return c0 != ' ' || c1 != ' ' ? MarkToken.EM_STAR : MarkToken.NONE; case '_': if (c1 == '_') { return c0 != ' ' || c2 != ' ' ? MarkToken.STRONG_UNDERSCORE : MarkToken.EM_UNDERSCORE; } return c0 != ' ' || c1 != ' ' ? MarkToken.EM_UNDERSCORE : MarkToken.NONE; case '~': if (c1 == '~') { return MarkToken.STRIKE; } return MarkToken.NONE; case '/': if (c1 == 'u' && c2 == '/') { return MarkToken.USER; } if (c1 == 'z' && c2 == '/') { return MarkToken.ZONE; } return MarkToken.NONE; case '[': return MarkToken.LINK; case ']': return MarkToken.NONE; case '`': return c1 == '`' ? MarkToken.CODE_DOUBLE : MarkToken.CODE_SINGLE; case '\\': switch (c1) { case '\\': case '[': case ']': case '(': case ')': case '{': case '}': case '#': case '"': case '\'': case '.': case '>': case '<': case '*': case '+': case '-': case '_': case '!': case '`': case '^': return MarkToken.ESCAPE; default: return MarkToken.NONE; } case '^': return c0 == '^' || c1 == '^' ? MarkToken.NONE : MarkToken.SUPER; default: return MarkToken.NONE; } } /** * Writes a set of markdown lines into the StringBuilder. * * @param out * The StringBuilder to write to. * @param lines * The lines to write. */ private void emitMarkedLines(final HtmlEscapeStringBuilder out, final Line lines) { final HtmlEscapeStringBuilder in = new HtmlEscapeStringBuilder(); Line line = lines; while (line != null) { if (!line.isEmpty) { in.append(line.value.substring(line.leading, line.value.length() - line.trailing)); if (line.trailing >= 2) { in.appendHtml("<br>"); } } if (line.next != null) { in.append('\n'); } line = line.next; } this.recursiveEmitLine(out, in.toString(), 0, MarkToken.NONE); } /** * Writes a code block into the StringBuilder. * * @param out * The StringBuilder to write to. * @param lines * The lines to write. * @param meta * Meta information. */ private void emitCodeLines(final HtmlEscapeStringBuilder out, final Line lines, final String meta) { Line line = lines; final ArrayList<String> list = new ArrayList<>(); while (line != null) { if (line.isEmpty) { list.add(""); } else { list.add(line.value); } line = line.next; } this.config.codeBlockEmitter.emitBlock(out, list, meta); } public void emitRefLinks(final HtmlEscapeStringBuilder out) { if (linkRefs.isEmpty()) { return; } out.appendHtml("<div class=\"reference-appendix-block\">"); linkRefs.forEach((s, linkRef) -> { out.appendHtml("<div class=\"reference-appendix-index\">") .append(linkRef.seqNumber) .appendHtml("</div>") .appendHtml("<div class=\"reference-appendix-wrap\">"); if (linkRef.hasHttpScheme()) { out.appendHtml("<a href=\"").append(linkRef.link).appendHtml("\""); if (!Strings.isNullOrEmpty(linkRef.title)) { out.appendHtml(" title=\"").append(linkRef.title).appendHtml("\""); } out.appendHtml(" rel=\"nofollow\" target=\"_blank\">") .append(linkRef.link) .appendHtml("</a>"); } else { out.append(linkRef.link); } out.appendHtml("</div>\n"); }); out.appendHtml("</div>"); } }