diff --git a/commonmark-ext-gfm-tables/src/main/java/org/commonmark/ext/gfm/tables/TableCell.java b/commonmark-ext-gfm-tables/src/main/java/org/commonmark/ext/gfm/tables/TableCell.java index 61880c6c377ec2382fa337e6d464605ac33468b5..bd7b40e02154ec99d4efd8138facd7998cd487cf 100644 --- a/commonmark-ext-gfm-tables/src/main/java/org/commonmark/ext/gfm/tables/TableCell.java +++ b/commonmark-ext-gfm-tables/src/main/java/org/commonmark/ext/gfm/tables/TableCell.java @@ -22,7 +22,7 @@ public class TableCell extends CustomNode { } /** - * @return the cell alignment + * @return the cell alignment or {@code null} if no specific alignment */ public Alignment getAlignment() { return alignment; diff --git a/commonmark-ext-gfm-tables/src/main/java/org/commonmark/ext/gfm/tables/TablesExtension.java b/commonmark-ext-gfm-tables/src/main/java/org/commonmark/ext/gfm/tables/TablesExtension.java index 5707b0f1406f24b893aa8e08ca2ea754869b2260..d23f6f5fc35bb6bfaa86044ded3510dd0c15550f 100644 --- a/commonmark-ext-gfm-tables/src/main/java/org/commonmark/ext/gfm/tables/TablesExtension.java +++ b/commonmark-ext-gfm-tables/src/main/java/org/commonmark/ext/gfm/tables/TablesExtension.java @@ -3,12 +3,16 @@ package org.commonmark.ext.gfm.tables; import org.commonmark.Extension; import org.commonmark.ext.gfm.tables.internal.TableBlockParser; import org.commonmark.ext.gfm.tables.internal.TableHtmlNodeRenderer; +import org.commonmark.ext.gfm.tables.internal.TableMarkdownNodeRenderer; import org.commonmark.ext.gfm.tables.internal.TableTextContentNodeRenderer; import org.commonmark.parser.Parser; import org.commonmark.renderer.NodeRenderer; import org.commonmark.renderer.html.HtmlNodeRendererContext; import org.commonmark.renderer.html.HtmlNodeRendererFactory; import org.commonmark.renderer.html.HtmlRenderer; +import org.commonmark.renderer.markdown.MarkdownNodeRendererContext; +import org.commonmark.renderer.markdown.MarkdownNodeRendererFactory; +import org.commonmark.renderer.markdown.MarkdownRenderer; import org.commonmark.renderer.text.TextContentNodeRendererContext; import org.commonmark.renderer.text.TextContentNodeRendererFactory; import org.commonmark.renderer.text.TextContentRenderer; @@ -27,7 +31,7 @@ import org.commonmark.renderer.text.TextContentRenderer; * @see <a href="https://github.github.com/gfm/#tables-extension-">Tables (extension) in GitHub Flavored Markdown Spec</a> */ public class TablesExtension implements Parser.ParserExtension, HtmlRenderer.HtmlRendererExtension, - TextContentRenderer.TextContentRendererExtension { + TextContentRenderer.TextContentRendererExtension, MarkdownRenderer.MarkdownRendererExtension { private TablesExtension() { } @@ -60,4 +64,14 @@ public class TablesExtension implements Parser.ParserExtension, HtmlRenderer.Htm } }); } + + @Override + public void extend(MarkdownRenderer.Builder rendererBuilder) { + rendererBuilder.nodeRendererFactory(new MarkdownNodeRendererFactory() { + @Override + public NodeRenderer create(MarkdownNodeRendererContext context) { + return new TableMarkdownNodeRenderer(context); + } + }); + } } diff --git a/commonmark-ext-gfm-tables/src/main/java/org/commonmark/ext/gfm/tables/internal/TableMarkdownNodeRenderer.java b/commonmark-ext-gfm-tables/src/main/java/org/commonmark/ext/gfm/tables/internal/TableMarkdownNodeRenderer.java new file mode 100644 index 0000000000000000000000000000000000000000..bf94db5bbd51b1a464c08b178bd588cf64dd1e17 --- /dev/null +++ b/commonmark-ext-gfm-tables/src/main/java/org/commonmark/ext/gfm/tables/internal/TableMarkdownNodeRenderer.java @@ -0,0 +1,96 @@ +package org.commonmark.ext.gfm.tables.internal; + +import org.commonmark.ext.gfm.tables.*; +import org.commonmark.internal.util.AsciiMatcher; +import org.commonmark.internal.util.CharMatcher; +import org.commonmark.node.Node; +import org.commonmark.renderer.NodeRenderer; +import org.commonmark.renderer.markdown.MarkdownNodeRendererContext; +import org.commonmark.renderer.markdown.MarkdownWriter; + +import java.util.ArrayList; +import java.util.List; + +/** + * The Table node renderer that is needed for rendering GFM tables (GitHub Flavored Markdown) to text content. + */ +public class TableMarkdownNodeRenderer extends TableNodeRenderer implements NodeRenderer { + private final MarkdownWriter writer; + private final MarkdownNodeRendererContext context; + + private final CharMatcher pipe = AsciiMatcher.builder().c('|').build(); + + private final List<TableCell.Alignment> columns = new ArrayList<>(); + + public TableMarkdownNodeRenderer(MarkdownNodeRendererContext context) { + this.writer = context.getWriter(); + this.context = context; + } + + @Override + protected void renderBlock(TableBlock node) { + columns.clear(); + renderChildren(node); + writer.block(); + } + + @Override + protected void renderHead(TableHead node) { + renderChildren(node); + // TODO: Not sure about this.. Should block() detect if a line was already written? Or should line() itself be lazy? + writer.line(); + for (TableCell.Alignment columnAlignment : columns) { + writer.raw('|'); + if (columnAlignment == TableCell.Alignment.LEFT) { + writer.raw(":---"); + } else if (columnAlignment == TableCell.Alignment.RIGHT) { + writer.raw("---:"); + } else if (columnAlignment == TableCell.Alignment.CENTER) { + writer.raw(":---:"); + } else { + writer.raw("---"); + } + } + writer.raw("|"); + // TODO + if (node.getNext() != null) { + writer.line(); + } + } + + @Override + protected void renderBody(TableBody node) { + renderChildren(node); + } + + @Override + protected void renderRow(TableRow node) { + renderChildren(node); + // Trailing | at the end of the line + writer.raw("|"); + // TODO + if (node.getNext() != null) { + writer.line(); + } + } + + @Override + protected void renderCell(TableCell node) { + if (node.getParent() != null && node.getParent().getParent() instanceof TableHead) { + columns.add(node.getAlignment()); + } + writer.raw("|"); + writer.pushRawEscape(pipe); + renderChildren(node); + writer.popRawEscape(); + } + + private void renderChildren(Node parent) { + Node node = parent.getFirstChild(); + while (node != null) { + Node next = node.getNext(); + context.render(node); + node = next; + } + } +} diff --git a/commonmark-ext-gfm-tables/src/test/java/org/commonmark/ext/gfm/tables/TablesMarkdownRenderingTest.java b/commonmark-ext-gfm-tables/src/test/java/org/commonmark/ext/gfm/tables/TablesMarkdownRenderingTest.java new file mode 100644 index 0000000000000000000000000000000000000000..8689e4ac2f7d1622c2b94caeaad72b4cfccbf56c --- /dev/null +++ b/commonmark-ext-gfm-tables/src/test/java/org/commonmark/ext/gfm/tables/TablesMarkdownRenderingTest.java @@ -0,0 +1,70 @@ +package org.commonmark.ext.gfm.tables; + +import org.commonmark.Extension; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.markdown.MarkdownRenderer; +import org.junit.Test; + +import java.util.Collections; +import java.util.Set; + +import static org.junit.Assert.assertEquals; + +public class TablesMarkdownRenderingTest { + + private static final Set<Extension> EXTENSIONS = Collections.singleton(TablesExtension.create()); + private static final Parser PARSER = Parser.builder().extensions(EXTENSIONS).build(); + private static final MarkdownRenderer RENDERER = MarkdownRenderer.builder().extensions(EXTENSIONS).build(); + + @Test + public void testHeadNoBody() { + assertRoundTrip("|Abc|\n|---|\n"); + assertRoundTrip("|Abc|Def|\n|---|---|\n"); + assertRoundTrip("|Abc||\n|---|---|\n"); + } + + @Test + public void testHeadAndBody() { + assertRoundTrip("|Abc|\n|---|\n|1|\n"); + assertRoundTrip("|Abc|Def|\n|---|---|\n|1|2|\n"); + } + + @Test + public void testBodyHasFewerColumns() { + // Could try not to write empty trailing cells but this is fine too + assertRoundTrip("|Abc|Def|\n|---|---|\n|1||\n"); + } + + @Test + public void testAlignment() { + assertRoundTrip("|Abc|Def|\n|:---|---|\n|1|2|\n"); + assertRoundTrip("|Abc|Def|\n|---|---:|\n|1|2|\n"); + assertRoundTrip("|Abc|Def|\n|:---:|:---:|\n|1|2|\n"); + } + + @Test + public void testInsideBlockQuote() { + assertRoundTrip("> |Abc|Def|\n> |---|---|\n> |1|2|\n"); + } + + @Test + public void testMultipleTables() { + assertRoundTrip("|Abc|Def|\n|---|---|\n\n|One|\n|---|\n|Only|\n"); + } + + @Test + public void testEscaping() { + assertRoundTrip("|Abc|Def|\n|---|---|\n|Pipe in|text \\||\n"); + assertRoundTrip("|Abc|Def|\n|---|---|\n|Pipe in|code `\\|`|\n"); + assertRoundTrip("|Abc|Def|\n|---|---|\n|Inline HTML|<span>Foo\\|bar</span>|\n"); + } + + protected String render(String source) { + return RENDERER.render(PARSER.parse(source)); + } + + private void assertRoundTrip(String input) { + String rendered = render(input); + assertEquals(input, rendered); + } +} diff --git a/commonmark/src/main/java/org/commonmark/renderer/markdown/CoreMarkdownNodeRenderer.java b/commonmark/src/main/java/org/commonmark/renderer/markdown/CoreMarkdownNodeRenderer.java index f5a9377f6626145574f1ab7af31b6969a53aed8d..714144e89e22dce2d313bdd7f511a831cfd4f040 100644 --- a/commonmark/src/main/java/org/commonmark/renderer/markdown/CoreMarkdownNodeRenderer.java +++ b/commonmark/src/main/java/org/commonmark/renderer/markdown/CoreMarkdownNodeRenderer.java @@ -23,15 +23,15 @@ import java.util.regex.Pattern; public class CoreMarkdownNodeRenderer extends AbstractVisitor implements NodeRenderer { private final AsciiMatcher textEscape = - AsciiMatcher.builder().anyOf("[]<>`*&").build(); + AsciiMatcher.builder().anyOf("[]<>`*&\n\\").build(); private final CharMatcher textEscapeInHeading = AsciiMatcher.builder(textEscape).anyOf("#").build(); private final CharMatcher linkDestinationNeedsAngleBrackets = - AsciiMatcher.builder().c(' ').c('(').c(')').c('<').c('>').c('\\').build(); + AsciiMatcher.builder().c(' ').c('(').c(')').c('<').c('>').c('\n').c('\\').build(); private final CharMatcher linkDestinationEscapeInAngleBrackets = - AsciiMatcher.builder().c('<').c('>').build(); + AsciiMatcher.builder().c('<').c('>').c('\n').c('\\').build(); private final CharMatcher linkTitleEscapeInQuotes = - AsciiMatcher.builder().c('"').build(); + AsciiMatcher.builder().c('"').c('\n').c('\\').build(); private final Pattern orderedListMarkerPattern = Pattern.compile("^([0-9]{1,9})([.)])"); diff --git a/commonmark/src/main/java/org/commonmark/renderer/markdown/MarkdownWriter.java b/commonmark/src/main/java/org/commonmark/renderer/markdown/MarkdownWriter.java index 3b2ae18b02ab6a3830bfd69e156dd269cc06242e..f648d9c4ff30dbda8a38ff8ae69a5a84cbfc9818 100644 --- a/commonmark/src/main/java/org/commonmark/renderer/markdown/MarkdownWriter.java +++ b/commonmark/src/main/java/org/commonmark/renderer/markdown/MarkdownWriter.java @@ -17,25 +17,26 @@ public class MarkdownWriter { private char lastChar; private boolean atLineStart = true; private final LinkedList<String> prefixes = new LinkedList<>(); + private final LinkedList<CharMatcher> rawEscapes = new LinkedList<>(); public MarkdownWriter(Appendable out) { buffer = out; } /** - * Write the supplied string (raw/unescaped). + * Write the supplied string (raw/unescaped except if {@link #pushRawEscape} was used). */ public void raw(String s) { flushBlockSeparator(); - append(s); + write(s, null); } /** - * Write the supplied character (raw/unescaped). + * Write the supplied character (raw/unescaped except if {@link #pushRawEscape} was used). */ public void raw(char c) { flushBlockSeparator(); - append(c); + write(c); } /** @@ -49,22 +50,7 @@ public class MarkdownWriter { return; } flushBlockSeparator(); - try { - for (int i = 0; i < s.length(); i++) { - char ch = s.charAt(i); - if (ch == '\n') { - // Can't escape this with \, use numeric character reference - buffer.append(" "); - } else { - if (ch == '\\' || escape.matches(ch)) { - buffer.append('\\'); - } - buffer.append(ch); - } - } - } catch (IOException e) { - throw new RuntimeException(e); - } + write(s, escape); lastChar = s.charAt(s.length() - 1); atLineStart = false; @@ -74,7 +60,7 @@ public class MarkdownWriter { * Write a newline (line terminator). */ public void line() { - append('\n'); + write('\n'); writePrefixes(); atLineStart = true; } @@ -118,6 +104,24 @@ public class MarkdownWriter { prefixes.removeLast(); } + /** + * Escape the characters matching the supplied matcher, in all text (text and raw). This might be useful to + * extensions that add another layer of syntax, e.g. the tables extension that uses `|` to separate cells and needs + * all `|` characters to be escaped (even in code spans). + * + * @param rawEscape the characters to escape in raw text + */ + public void pushRawEscape(CharMatcher rawEscape) { + rawEscapes.add(rawEscape); + } + + /** + * Remove the last raw escape from the top of the stack. + */ + public void popRawEscape() { + rawEscapes.removeLast(); + } + /** * @return the last character that was written */ @@ -151,9 +155,16 @@ public class MarkdownWriter { this.tight = tight; } - private void append(String s) { + private void write(String s, CharMatcher escape) { try { - buffer.append(s); + if (rawEscapes.isEmpty() && escape == null) { + // Normal fast path + buffer.append(s); + } else { + for (int i = 0; i < s.length(); i++) { + append(s.charAt(i), escape); + } + } } catch (IOException e) { throw new RuntimeException(e); } @@ -165,9 +176,9 @@ public class MarkdownWriter { atLineStart = false; } - private void append(char c) { + private void write(char c) { try { - buffer.append(c); + append(c, null); } catch (IOException e) { throw new RuntimeException(e); } @@ -179,7 +190,7 @@ public class MarkdownWriter { private void writePrefixes() { if (!prefixes.isEmpty()) { for (String prefix : prefixes) { - append(prefix); + write(prefix, null); } } } @@ -189,13 +200,40 @@ public class MarkdownWriter { */ private void flushBlockSeparator() { if (blockSeparator != 0) { - append('\n'); + write('\n'); writePrefixes(); if (blockSeparator > 1) { - append('\n'); + write('\n'); writePrefixes(); } blockSeparator = 0; } } + + private void append(char c, CharMatcher escape) throws IOException { + if (needsEscaping(c, escape)) { + if (c == '\n') { + // Can't escape this with \, use numeric character reference + buffer.append(" "); + } else { + buffer.append('\\'); + buffer.append(c); + } + } else { + buffer.append(c); + } + } + + private boolean needsEscaping(char c, CharMatcher escape) { + return (escape != null && escape.matches(c)) || rawNeedsEscaping(c); + } + + private boolean rawNeedsEscaping(char c) { + for (CharMatcher rawEscape : rawEscapes) { + if (rawEscape.matches(c)) { + return true; + } + } + return false; + } } diff --git a/commonmark/src/test/java/org/commonmark/renderer/markdown/MarkdownRendererTest.java b/commonmark/src/test/java/org/commonmark/renderer/markdown/MarkdownRendererTest.java index bc6d9b696b0f5b3e3a3b1667b96899a89e130571..20453eed7fb5ca6a6f9be4260120cea9f9119feb 100644 --- a/commonmark/src/test/java/org/commonmark/renderer/markdown/MarkdownRendererTest.java +++ b/commonmark/src/test/java/org/commonmark/renderer/markdown/MarkdownRendererTest.java @@ -194,6 +194,9 @@ public class MarkdownRendererTest { assertRoundTrip("[a](<b\\>c>)\n"); assertRoundTrip("[a](<b\\\\\\>c>)\n"); assertRoundTrip("[a](/uri \"foo \\\" bar\")\n"); + assertRoundTrip("[link](/uri \"tes\\\\\")\n"); + assertRoundTrip("[link](/url \"test \")\n"); + assertRoundTrip("[link](</url >)\n"); } @Test