diff --git a/README.md b/README.md index bb5433a6..9a08d469 100644 --- a/README.md +++ b/README.md @@ -110,24 +110,19 @@ Lorem ipsum dolor sit amet ### H.T.M.L. OKA424342Y - -alt text +### Tables +Header #1 | Header #2 | Header #3 +---: | :---: | :--- +content | content | content +long long long skjfs fgjsdfhj sf `dfk df` | sdsd,fklsdfklsdfklsdfkl sdfkl dsfjksdf sjkf jksdfjksdf sjkdf sdfkjsdjkf sdkjfs fkjsf sdkjfs fkjsd fkjsdf skjdf sdkjf skjfs fkjs fkjsdf jskdf sdjkf sjdkf sdkjf skjf sdkjf sdkjf sdfkjsd fkjsd fkjsdf sdkjfsjk dfkjsdf sdkjfs | yeah -
-
- -

Hello

- -

Hello

- -

Hello

- -

Hello

- -
Hello
- -
Hello
+|head #1| head #2| +|---|---| +| content | content | +| content | content | +| content | content | +| content | content | [1]: https://github.com diff --git a/app/src/main/java/ru/noties/markwon/MainActivity.java b/app/src/main/java/ru/noties/markwon/MainActivity.java index ba311cbd..8b5b10dc 100644 --- a/app/src/main/java/ru/noties/markwon/MainActivity.java +++ b/app/src/main/java/ru/noties/markwon/MainActivity.java @@ -60,7 +60,7 @@ public class MainActivity extends Activity { markdownLoader.load(uri(), new MarkdownLoader.OnMarkdownTextLoaded() { @Override - public void apply(String text) { + public void apply(final String text) { markdownRenderer.render(MainActivity.this, uri(), text, new MarkdownRenderer.MarkdownReadyListener() { @Override public void onMarkdownReady(CharSequence markdown) { diff --git a/build.gradle b/build.gradle index ece44916..5391065a 100644 --- a/build.gradle +++ b/build.gradle @@ -32,6 +32,7 @@ ext { final def commonMarkVersion = '0.9.0' COMMON_MARK = "com.atlassian.commonmark:commonmark:$commonMarkVersion" COMMON_MARK_STRIKETHROUGHT = "com.atlassian.commonmark:commonmark-ext-gfm-strikethrough:$commonMarkVersion" + COMMON_MARK_TABLE = "com.atlassian.commonmark:commonmark-ext-gfm-tables:$commonMarkVersion" ANDROID_SVG = 'com.caverock:androidsvg:1.2.1' ANDROID_GIF = 'pl.droidsonroids.gif:android-gif-drawable:1.2.7' diff --git a/library-renderer/build.gradle b/library-renderer/build.gradle index 7d224d7f..3215ca12 100644 --- a/library-renderer/build.gradle +++ b/library-renderer/build.gradle @@ -17,4 +17,7 @@ dependencies { compile SUPPORT_ANNOTATIONS compile COMMON_MARK compile COMMON_MARK_STRIKETHROUGHT + compile COMMON_MARK_TABLE + + compile 'ru.noties:debug:3.0.0@jar' } diff --git a/library-renderer/src/main/java/ru/noties/markwon/Markwon.java b/library-renderer/src/main/java/ru/noties/markwon/Markwon.java index 739f98a5..e76deb38 100644 --- a/library-renderer/src/main/java/ru/noties/markwon/Markwon.java +++ b/library-renderer/src/main/java/ru/noties/markwon/Markwon.java @@ -8,10 +8,11 @@ import android.text.method.LinkMovementMethod; import android.widget.TextView; import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension; +import org.commonmark.ext.gfm.tables.TablesExtension; import org.commonmark.node.Node; import org.commonmark.parser.Parser; -import java.util.Collections; +import java.util.Arrays; import ru.noties.markwon.renderer.SpannableRenderer; @@ -20,7 +21,7 @@ public abstract class Markwon { public static Parser createParser() { return new Parser.Builder() - .extensions(Collections.singleton(StrikethroughExtension.create())) + .extensions(Arrays.asList(StrikethroughExtension.create(), TablesExtension.create())) .build(); } @@ -40,6 +41,7 @@ public abstract class Markwon { public static void setText(@NonNull TextView view, CharSequence text) { unscheduleDrawables(view); + unscheduleTableRows(view); // update movement method (for links to be clickable) view.setMovementMethod(LinkMovementMethod.getInstance()); @@ -47,6 +49,7 @@ public abstract class Markwon { // schedule drawables (dynamic drawables that can change bounds/animate will be correctly updated) scheduleDrawables(view); + scheduleTableRows(view); } // with default configuration @@ -82,6 +85,14 @@ public abstract class Markwon { DrawablesScheduler.unschedule(view); } + public static void scheduleTableRows(@NonNull TextView view) { + TableRowsScheduler.schedule(view); + } + + public static void unscheduleTableRows(@NonNull TextView view) { + TableRowsScheduler.unschedule(view); + } + private Markwon() { } } diff --git a/library-renderer/src/main/java/ru/noties/markwon/TableRowsScheduler.java b/library-renderer/src/main/java/ru/noties/markwon/TableRowsScheduler.java new file mode 100644 index 00000000..ac931df1 --- /dev/null +++ b/library-renderer/src/main/java/ru/noties/markwon/TableRowsScheduler.java @@ -0,0 +1,64 @@ +package ru.noties.markwon; + +import android.support.annotation.NonNull; +import android.text.Spanned; +import android.text.TextUtils; +import android.view.View; +import android.widget.TextView; + +import ru.noties.markwon.spans.TableRowSpan; + +abstract class TableRowsScheduler { + + static void schedule(@NonNull final TextView view) { + final Object[] spans = extract(view); + if (spans != null + && spans.length > 0) { + view.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() { + @Override + public void onViewAttachedToWindow(View v) { + + } + + @Override + public void onViewDetachedFromWindow(View v) { + unschedule(view); + view.removeOnAttachStateChangeListener(this); + } + }); + final TableRowSpan.Invalidator invalidator = new TableRowSpan.Invalidator() { + @Override + public void invalidate() { + view.setText(view.getText()); + } + }; + for (Object span : spans) { + ((TableRowSpan) span).invalidator(invalidator); + } + } + } + + static void unschedule(@NonNull TextView view) { + final Object[] spans = extract(view); + if (spans != null + && spans.length > 0) { + for (Object span : spans) { + ((TableRowSpan) span).invalidator(null); + } + } + } + + private static Object[] extract(@NonNull TextView view) { + final Object[] out; + final CharSequence text = view.getText(); + if (!TextUtils.isEmpty(text) && text instanceof Spanned) { + out = ((Spanned) text).getSpans(0, text.length(), TableRowSpan.class); + } else { + out = null; + } + return out; + } + + private TableRowsScheduler() { + } +} diff --git a/library-renderer/src/main/java/ru/noties/markwon/renderer/SpannableMarkdownVisitor.java b/library-renderer/src/main/java/ru/noties/markwon/renderer/SpannableMarkdownVisitor.java index 70fef6a6..ba64fdf9 100644 --- a/library-renderer/src/main/java/ru/noties/markwon/renderer/SpannableMarkdownVisitor.java +++ b/library-renderer/src/main/java/ru/noties/markwon/renderer/SpannableMarkdownVisitor.java @@ -7,6 +7,9 @@ import android.text.TextUtils; import android.text.style.StrikethroughSpan; import org.commonmark.ext.gfm.strikethrough.Strikethrough; +import org.commonmark.ext.gfm.tables.TableBody; +import org.commonmark.ext.gfm.tables.TableCell; +import org.commonmark.ext.gfm.tables.TableRow; import org.commonmark.node.AbstractVisitor; import org.commonmark.node.BlockQuote; import org.commonmark.node.BulletList; @@ -31,8 +34,11 @@ import org.commonmark.node.Text; import org.commonmark.node.ThematicBreak; import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.Deque; +import java.util.List; +import ru.noties.debug.Debug; import ru.noties.markwon.SpannableConfiguration; import ru.noties.markwon.renderer.html.SpannableHtmlParser; import ru.noties.markwon.spans.AsyncDrawable; @@ -45,6 +51,7 @@ import ru.noties.markwon.spans.HeadingSpan; import ru.noties.markwon.spans.LinkSpan; import ru.noties.markwon.spans.OrderedListItemSpan; import ru.noties.markwon.spans.StrongEmphasisSpan; +import ru.noties.markwon.spans.TableRowSpan; import ru.noties.markwon.spans.ThematicBreakSpan; @SuppressWarnings("WeakerAccess") @@ -259,17 +266,85 @@ public class SpannableMarkdownVisitor extends AbstractVisitor { newLine(); } + private List pendingTableRow; + private boolean tableRowIsHeader; + private int tableRows; + @Override public void visit(CustomNode customNode) { + +// Log.e(null, String.valueOf(customNode)); + if (customNode instanceof Strikethrough) { + final int length = builder.length(); visitChildren(customNode); setSpan(length, new StrikethroughSpan()); - } else { + + } else if (!handleTableNodes(customNode)) { super.visit(customNode); } } + private boolean handleTableNodes(CustomNode node) { + + final boolean handled; + + Debug.i(node); + + if (node instanceof TableBody) { + visitChildren(node); + tableRows = 0; + handled = true; + newLine(); + builder.append('\n'); + } else if (node instanceof TableRow) { + + final int length = builder.length(); + visitChildren(node); + + if (pendingTableRow != null) { + builder.append(' '); + + final TableRowSpan span = new TableRowSpan( + configuration.theme(), + pendingTableRow, + tableRowIsHeader, + tableRows % 2 == 1 + ); + + setSpan(length, span); + newLine(); + pendingTableRow = null; + } + + handled = true; + } else if (node instanceof TableCell) { + + final TableCell cell = (TableCell) node; + final int length = builder.length(); + visitChildren(cell); + if (pendingTableRow == null) { + pendingTableRow = new ArrayList<>(2); + } + pendingTableRow.add(new TableRowSpan.Cell( + tableCellAlignment(cell.getAlignment()), + builder.subSequence(length, builder.length()) + )); + builder.replace(length, builder.length(), ""); + + tableRowIsHeader = cell.isHeader(); + tableRows = tableRowIsHeader + ? 0 + : tableRows + 1; + + handled = true; + } else { + handled = false; + } + return handled; + } + @Override public void visit(Paragraph paragraph) { @@ -402,6 +477,27 @@ public class SpannableMarkdownVisitor extends AbstractVisitor { return false; } + @TableRowSpan.Alignment + private static int tableCellAlignment(TableCell.Alignment alignment) { + final int out; + if (alignment != null) { + switch (alignment) { + case CENTER: + out = TableRowSpan.ALIGN_CENTER; + break; + case RIGHT: + out = TableRowSpan.ALIGN_RIGHT; + break; + default: + out = TableRowSpan.ALIGN_LEFT; + break; + } + } else { + out = TableRowSpan.ALIGN_LEFT; + } + return out; + } + private static class HtmlInlineItem { final SpannableHtmlParser.Tag tag; diff --git a/library-renderer/src/main/java/ru/noties/markwon/spans/SpannableTheme.java b/library-renderer/src/main/java/ru/noties/markwon/spans/SpannableTheme.java index c25878ce..b8e4bfa6 100644 --- a/library-renderer/src/main/java/ru/noties/markwon/spans/SpannableTheme.java +++ b/library-renderer/src/main/java/ru/noties/markwon/spans/SpannableTheme.java @@ -16,7 +16,7 @@ public class SpannableTheme { // this method should be used if TextView is known beforehand // it will correctly measure the `space` char and set it as `codeMultilineMargin` - // otherwise this value must be set explicitly ( + // otherwise this value must be set explicitly public static SpannableTheme create(@NonNull TextView textView) { return builderWithDefaults(textView.getContext()) .codeMultilineMargin((int) (textView.getPaint().measureText("\u00a0") + .5F)) diff --git a/library-renderer/src/main/java/ru/noties/markwon/spans/TableRowSpan.java b/library-renderer/src/main/java/ru/noties/markwon/spans/TableRowSpan.java new file mode 100644 index 00000000..f5fc26f4 --- /dev/null +++ b/library-renderer/src/main/java/ru/noties/markwon/spans/TableRowSpan.java @@ -0,0 +1,217 @@ +package ru.noties.markwon.spans; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.support.annotation.IntDef; +import android.support.annotation.IntRange; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.Layout; +import android.text.StaticLayout; +import android.text.TextPaint; +import android.text.style.ReplacementSpan; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.List; + +public class TableRowSpan extends ReplacementSpan { + + public static final int ALIGN_LEFT = 0; + public static final int ALIGN_CENTER = 1; + public static final int ALIGN_RIGHT = 2; + + @IntDef(value = {ALIGN_LEFT, ALIGN_CENTER, ALIGN_RIGHT}) + @Retention(RetentionPolicy.SOURCE) + public @interface Alignment { + } + + public interface Invalidator { + void invalidate(); + } + + public static class Cell { + + final int alignment; + final CharSequence text; + + public Cell(@Alignment int alignment, CharSequence text) { + this.alignment = alignment; + this.text = text; + } + + @Alignment + public int alignment() { + return alignment; + } + + public CharSequence text() { + return text; + } + + @Override + public String toString() { + return "Cell{" + + "alignment=" + alignment + + ", text=" + text + + '}'; + } + } + + private final SpannableTheme theme; + private final List cells; + private final List layouts; + private final TextPaint textPaint; + private final boolean header; + private final boolean odd; + + private int width; + private int height; + private Invalidator invalidator; + + public TableRowSpan( + @NonNull SpannableTheme theme, + @NonNull List cells, + boolean header, + boolean odd) { + this.theme = theme; + this.cells = cells; + this.layouts = new ArrayList<>(cells.size()); + this.textPaint = new TextPaint(); + this.header = header; + this.odd = odd; + } + + @Override + public int getSize( + @NonNull Paint paint, + CharSequence text, + @IntRange(from = 0) int start, + @IntRange(from = 0) int end, + @Nullable Paint.FontMetricsInt fm) { + + // it's our absolute requirement to have width of the canvas here... because, well, it changes + // the way we draw text. So, if we do not know the width of canvas we cannot correctly measure our text + + if (layouts.size() > 0) { + + if (fm != null) { + + int max = 0; + for (StaticLayout layout : layouts) { + final int height = layout.getHeight(); + if (height > max) { + max = height; + } + } + + height = max; + + fm.ascent = -max; + fm.descent = 0; + + fm.top = fm.ascent; + fm.bottom = 0; + } + } + + return width; + } + + @Override + public void draw( + @NonNull Canvas canvas, + CharSequence text, + @IntRange(from = 0) int start, + @IntRange(from = 0) int end, + float x, + int top, + int y, + int bottom, + @NonNull Paint paint) { + + if (recreateLayouts(canvas.getWidth())) { + width = canvas.getWidth(); + textPaint.set(paint); + makeNewLayouts(); + } + + int maxHeight = 0; + + StaticLayout layout; + for (int i = 0, size = layouts.size(); i < size; i++) { + layout = layouts.get(i); + final int save = canvas.save(); + try { + + canvas.translate(x + (i * layout.getWidth()), top); + layout.draw(canvas); + + if (layout.getHeight() > maxHeight) { + maxHeight = layout.getHeight(); + } + + } finally { + canvas.restoreToCount(save); + } + } + + if (height != maxHeight) { + if (invalidator != null) { + invalidator.invalidate(); + } + } + } + + private boolean recreateLayouts(int newWidth) { + return width != newWidth; + } + + private void makeNewLayouts() { + + if (header) { + textPaint.setFakeBoldText(true); + } + + final int w = width / cells.size(); + + this.layouts.clear(); + Cell cell; + StaticLayout layout; + for (int i = 0, size = cells.size(); i < size; i++) { + cell = cells.get(i); + layout = new StaticLayout( + cell.text, + textPaint, + w, + alignment(cell.alignment), + 1.F, + .0F, + false + ); + layouts.add(layout); + } + } + + private static Layout.Alignment alignment(@Alignment int alignment) { + final Layout.Alignment out; + switch (alignment) { + case ALIGN_CENTER: + out = Layout.Alignment.ALIGN_CENTER; + break; + case ALIGN_RIGHT: + out = Layout.Alignment.ALIGN_OPPOSITE; + break; + default: + out = Layout.Alignment.ALIGN_NORMAL; + break; + } + return out; + } + + public TableRowSpan invalidator(Invalidator invalidator) { + this.invalidator = invalidator; + return this; + } +}