From 537081a268d365e62c353786d4f2acfa40662183 Mon Sep 17 00:00:00 2001 From: "maarten.vercruysse" Date: Wed, 28 Feb 2024 00:14:15 +0100 Subject: [PATCH] Attempt superscript --- app/build.gradle.kts | 1 + .../ui/components/common/MarkdownHelper.kt | 11 + .../util/markwon/script/Superscript.java | 22 ++ .../SuperscriptClosingInlineProcessor.java | 51 +++ .../markwon/script/SuperscriptOpening.java | 17 + .../script/SuperscriptOpeningBracket.java | 31 ++ .../SuperscriptOpeningInlineProcessor.java | 50 +++ .../script/SuperscriptOpeningStorage.java | 38 +++ .../markwon/script/SuperscriptPlugin.java | 295 ++++++++++++++++++ .../util/markwon/script/SuperscriptSpan.java | 34 ++ build.gradle.kts | 1 - 11 files changed, 550 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/jerboa/util/markwon/script/Superscript.java create mode 100644 app/src/main/java/com/jerboa/util/markwon/script/SuperscriptClosingInlineProcessor.java create mode 100644 app/src/main/java/com/jerboa/util/markwon/script/SuperscriptOpening.java create mode 100644 app/src/main/java/com/jerboa/util/markwon/script/SuperscriptOpeningBracket.java create mode 100644 app/src/main/java/com/jerboa/util/markwon/script/SuperscriptOpeningInlineProcessor.java create mode 100644 app/src/main/java/com/jerboa/util/markwon/script/SuperscriptOpeningStorage.java create mode 100644 app/src/main/java/com/jerboa/util/markwon/script/SuperscriptPlugin.java create mode 100644 app/src/main/java/com/jerboa/util/markwon/script/SuperscriptSpan.java diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 32583d931..637451209 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -117,6 +117,7 @@ dependencies { implementation("io.noties.markwon:core:4.6.2") implementation("io.noties.markwon:ext-strikethrough:4.6.2") implementation("io.noties.markwon:ext-tables:4.6.2") + implementation("io.noties.markwon:inline-parser:4.6.2") implementation("io.noties.markwon:html:4.6.2") implementation("io.noties.markwon:image-coil:4.6.2") implementation("io.noties.markwon:linkify:4.6.2") diff --git a/app/src/main/java/com/jerboa/ui/components/common/MarkdownHelper.kt b/app/src/main/java/com/jerboa/ui/components/common/MarkdownHelper.kt index dd431924a..d68408538 100644 --- a/app/src/main/java/com/jerboa/ui/components/common/MarkdownHelper.kt +++ b/app/src/main/java/com/jerboa/ui/components/common/MarkdownHelper.kt @@ -32,6 +32,7 @@ import com.jerboa.util.markwon.BetterLinkMovementMethod import com.jerboa.util.markwon.ForceHttpsPlugin import com.jerboa.util.markwon.MarkwonLemmyLinkPlugin import com.jerboa.util.markwon.MarkwonSpoilerPlugin +import com.jerboa.util.markwon.script.SuperscriptPlugin import io.noties.markwon.AbstractMarkwonPlugin import io.noties.markwon.Markwon import io.noties.markwon.MarkwonConfiguration @@ -42,6 +43,8 @@ import io.noties.markwon.html.HtmlPlugin import io.noties.markwon.html.TagHandlerNoOp import io.noties.markwon.image.AsyncDrawableSpan import io.noties.markwon.image.coil.ClickableCoilImagesPlugin +import io.noties.markwon.inlineparser.HtmlInlineProcessor +import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin import io.noties.markwon.linkify.LinkifyPlugin import io.noties.markwon.movement.MovementMethodPlugin import java.util.regex.Pattern @@ -92,6 +95,10 @@ object MarkdownHelper { .usePlugin(ForceHttpsPlugin()) // email urls interfere with lemmy links .usePlugin(LinkifyPlugin.create(Linkify.WEB_URLS)) + .usePlugin(MarkwonInlineParserPlugin.create { plugin -> + plugin.excludeInlineProcessor(HtmlInlineProcessor::class.java) + }) + .usePlugin(SuperscriptPlugin.create()) .usePlugin(MarkwonLemmyLinkPlugin()) .usePlugin(MarkwonSpoilerPlugin(true)) .usePlugin(StrikethroughPlugin.create()) @@ -126,6 +133,10 @@ object MarkdownHelper { Markwon.builder(context) // email urls interfere with lemmy links .usePlugin(LinkifyPlugin.create(Linkify.WEB_URLS)) + .usePlugin(MarkwonInlineParserPlugin.create { plugin -> + plugin.excludeInlineProcessor(HtmlInlineProcessor::class.java) + }) + .usePlugin(SuperscriptPlugin.create()) .usePlugin(MarkwonLemmyLinkPlugin()) .usePlugin(StrikethroughPlugin.create()) .usePlugin(TablePlugin.create(context)) diff --git a/app/src/main/java/com/jerboa/util/markwon/script/Superscript.java b/app/src/main/java/com/jerboa/util/markwon/script/Superscript.java new file mode 100644 index 000000000..6a2ba1b53 --- /dev/null +++ b/app/src/main/java/com/jerboa/util/markwon/script/Superscript.java @@ -0,0 +1,22 @@ +package com.jerboa.util.markwon.script; + +import org.commonmark.node.CustomNode; +import org.commonmark.node.Visitor; + +// Source : https://codeberg.org/Bazsalanszky/Eternity/src/commit/3c871e26781d26df7b7f92b8633580b6087a8223/app/src/main/java/eu/toldi/infinityforlemmy/markdown/Superscript.java +public class Superscript extends CustomNode { + private boolean isBracketed; + + @Override + public void accept(Visitor visitor) { + visitor.visit(this); + } + + public boolean isBracketed() { + return isBracketed; + } + + public void setBracketed(boolean bracketed) { + isBracketed = bracketed; + } +} diff --git a/app/src/main/java/com/jerboa/util/markwon/script/SuperscriptClosingInlineProcessor.java b/app/src/main/java/com/jerboa/util/markwon/script/SuperscriptClosingInlineProcessor.java new file mode 100644 index 000000000..9abf7a33b --- /dev/null +++ b/app/src/main/java/com/jerboa/util/markwon/script/SuperscriptClosingInlineProcessor.java @@ -0,0 +1,51 @@ +package com.jerboa.util.markwon.script; + +import static io.noties.markwon.inlineparser.InlineParserUtils.mergeChildTextNodes; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.commonmark.node.Node; + +import io.noties.markwon.inlineparser.InlineProcessor; + +public class SuperscriptClosingInlineProcessor extends InlineProcessor { + @NonNull + private final SuperscriptOpeningStorage superscriptOpeningStorage; + + public SuperscriptClosingInlineProcessor(@NonNull SuperscriptOpeningStorage superscriptOpeningStorage) { + this.superscriptOpeningStorage = superscriptOpeningStorage; + } + + @Override + public char specialCharacter() { + return ')'; + } + + @Nullable + @Override + protected Node parse() { + SuperscriptOpeningBracket superscriptOpening = superscriptOpeningStorage.pop(block); + if (superscriptOpening == null) { + return null; + } + index++; + + Superscript superscript = new Superscript(); + superscript.setBracketed(true); + Node node = superscriptOpening.node.getNext(); + while (node != null) { + Node next = node.getNext(); + superscript.appendChild(node); + node = next; + } + + // Process delimiters such as emphasis inside spoiler + processDelimiters(superscriptOpening.previousDelimiter); + mergeChildTextNodes(superscript); + // We don't need the corresponding text node anymore, we turned it into a spoiler node + superscriptOpening.node.unlink(); + + return superscript; + } +} diff --git a/app/src/main/java/com/jerboa/util/markwon/script/SuperscriptOpening.java b/app/src/main/java/com/jerboa/util/markwon/script/SuperscriptOpening.java new file mode 100644 index 000000000..0b46d1832 --- /dev/null +++ b/app/src/main/java/com/jerboa/util/markwon/script/SuperscriptOpening.java @@ -0,0 +1,17 @@ +package com.jerboa.util.markwon.script; + +import org.commonmark.node.Node; + +public class SuperscriptOpening { + /** + * Node that contains non-bracketed superscript opening markdown ({@code ^}). + */ + public final Node node; + + public final Integer start; + + public SuperscriptOpening(Node node, int start) { + this.node = node; + this.start = start; + } +} diff --git a/app/src/main/java/com/jerboa/util/markwon/script/SuperscriptOpeningBracket.java b/app/src/main/java/com/jerboa/util/markwon/script/SuperscriptOpeningBracket.java new file mode 100644 index 000000000..0bee6439a --- /dev/null +++ b/app/src/main/java/com/jerboa/util/markwon/script/SuperscriptOpeningBracket.java @@ -0,0 +1,31 @@ +package com.jerboa.util.markwon.script; + +import org.commonmark.internal.Delimiter; +import org.commonmark.node.Node; + +public class SuperscriptOpeningBracket { + /** + * Node that contains superscript opening bracket markdown ({@code ^(}). + */ + public final Node node; + + /** + * Previous superscript opening bracket. + */ + public final SuperscriptOpeningBracket previous; + + /** + * Previous delimiter (emphasis, etc) before this bracket. + */ + public final Delimiter previousDelimiter; + + public final Integer start; + + public SuperscriptOpeningBracket(Node node, SuperscriptOpeningBracket previous, Delimiter previousDelimiter) { + this.node = node; + this.previous = previous; + this.previousDelimiter = previousDelimiter; + this.start = null; + } +} + diff --git a/app/src/main/java/com/jerboa/util/markwon/script/SuperscriptOpeningInlineProcessor.java b/app/src/main/java/com/jerboa/util/markwon/script/SuperscriptOpeningInlineProcessor.java new file mode 100644 index 000000000..7dbf63bb3 --- /dev/null +++ b/app/src/main/java/com/jerboa/util/markwon/script/SuperscriptOpeningInlineProcessor.java @@ -0,0 +1,50 @@ +package com.jerboa.util.markwon.script; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.commonmark.node.Node; +import org.commonmark.node.Text; + +import io.noties.markwon.inlineparser.InlineProcessor; + +public class SuperscriptOpeningInlineProcessor extends InlineProcessor { + @NonNull + private final SuperscriptOpeningStorage superscriptOpeningStorage; + + public SuperscriptOpeningInlineProcessor(@NonNull SuperscriptOpeningStorage superscriptOpeningStorage) { + this.superscriptOpeningStorage = superscriptOpeningStorage; + } + + @Override + public char specialCharacter() { + return '^'; + } + + @Nullable + @Override + protected Node parse() { + index++; + char c = peek(); + if (c != '\0' && !Character.isWhitespace(c)) { + if (c == '(') { + index++; + Text node = text("^("); + superscriptOpeningStorage.add(block, node, lastDelimiter()); + return node; + } + + if (lastDelimiter() != null && lastDelimiter().canOpen && block.getLastChild() != null) { + if (lastDelimiter().node == this.block.getLastChild()) { + if (lastDelimiter().delimiterChar == peek()) { + index--; + return null; + } + } + } + + return new Superscript(); + } + return null; + } +} diff --git a/app/src/main/java/com/jerboa/util/markwon/script/SuperscriptOpeningStorage.java b/app/src/main/java/com/jerboa/util/markwon/script/SuperscriptOpeningStorage.java new file mode 100644 index 000000000..d9bf18e53 --- /dev/null +++ b/app/src/main/java/com/jerboa/util/markwon/script/SuperscriptOpeningStorage.java @@ -0,0 +1,38 @@ +package com.jerboa.util.markwon.script; + +import androidx.annotation.Nullable; + +import org.commonmark.internal.Delimiter; +import org.commonmark.node.Node; + +public class SuperscriptOpeningStorage { + @Nullable + private SuperscriptOpeningBracket lastBracket; + private Node currentBlock; + + public void clear() { + lastBracket = null; + } + + public void add(Node block, Node node, Delimiter lastDelimiter) { + updateBlock(block); + lastBracket = new SuperscriptOpeningBracket(node, lastBracket, lastDelimiter); + } + + @Nullable + public SuperscriptOpeningBracket pop(Node block) { + updateBlock(block); + SuperscriptOpeningBracket opening = lastBracket; + if (opening != null) { + lastBracket = opening.previous; + } + return opening; + } + + private void updateBlock(Node block) { + if (block != currentBlock) { + clear(); + } + currentBlock = block; + } +} diff --git a/app/src/main/java/com/jerboa/util/markwon/script/SuperscriptPlugin.java b/app/src/main/java/com/jerboa/util/markwon/script/SuperscriptPlugin.java new file mode 100644 index 000000000..162691bf1 --- /dev/null +++ b/app/src/main/java/com/jerboa/util/markwon/script/SuperscriptPlugin.java @@ -0,0 +1,295 @@ +package com.jerboa.util.markwon.script; + +import android.text.Spannable; +import android.text.Spanned; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +import org.commonmark.ext.gfm.tables.TableCell; +import org.commonmark.node.Link; +import org.commonmark.node.Node; +import org.commonmark.node.Paragraph; +import org.commonmark.node.Text; + +import java.util.ArrayList; +import java.util.List; + +import io.noties.markwon.AbstractMarkwonPlugin; +import io.noties.markwon.MarkwonSpansFactory; +import io.noties.markwon.MarkwonVisitor; +import io.noties.markwon.core.spans.CodeSpan; +import io.noties.markwon.core.spans.TextViewSpan; +import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin; + +// Source : https://codeberg.org/Bazsalanszky/Eternity/src/commit/3c871e26781d26df7b7f92b8633580b6087a8223/app/src/main/java/eu/toldi/infinityforlemmy/markdown/SuperscriptPlugin.java +public class SuperscriptPlugin extends AbstractMarkwonPlugin { + private final SuperscriptOpeningStorage superscriptOpeningBracketStorage; + private final List superscriptOpeningList; + + SuperscriptPlugin() { + this.superscriptOpeningBracketStorage = new SuperscriptOpeningStorage(); + this.superscriptOpeningList = new ArrayList<>(); + } + + @NonNull + public static SuperscriptPlugin create() { + return new SuperscriptPlugin(); + } + + private static char peek(int index, CharSequence input) { + return index >= 0 && index < input.length() ? input.charAt(index) : '\0'; + } + + private static List getSpans(Spannable spannable, int start, int end) { + var spanArray = spannable.getSpans(start, end, Object.class); + List spanList = new ArrayList<>(); + for (int i = spanArray.length - 1; i >= 0; i--) { + Object span = spanArray[i]; + int spanStart = spannable.getSpanStart(span); + int spanEnd = spannable.getSpanEnd(span); + int spanFlags = spannable.getSpanFlags(span); + spanList.add(new SpanInfo(span, spanStart, spanEnd, spanFlags)); + } + return spanList; + } + + private static SpanInfo matchSuperscriptAtPosition(List spans, int value) { + for (var span : spans) + if (span.what.getClass() == SuperscriptSpan.class && !((SuperscriptSpan) span.what).isBracketed && span.start <= value && value <= span.end) + return span; + return null; + } + + private static SpanInfo matchSpanAtPosition(List spans, int value, Object spanClass) { + for (var span : spans) + if (span.what.getClass() == spanClass && span.start <= value && value <= span.end) + return span; + return null; + } + + private static SpanInfo matchNonTextSpanAtBoundary(List spans, int value) { + for (var span : spans) + if ((span.end == value || span.start == value) && span.what.getClass() != CodeSpan.class && span.what.getClass() != SuperscriptSpan.class && span.what.getClass() != TextViewSpan.class) + return span; + return null; + } + + @Override + public void configure(@NonNull Registry registry) { + registry.require(MarkwonInlineParserPlugin.class, plugin -> { + plugin.factoryBuilder().addInlineProcessor(new SuperscriptOpeningInlineProcessor(superscriptOpeningBracketStorage)); + plugin.factoryBuilder().addInlineProcessor(new SuperscriptClosingInlineProcessor(superscriptOpeningBracketStorage)); + } + ); + } + + @Override + public void configureSpansFactory(@NonNull MarkwonSpansFactory.Builder builder) { + builder.setFactory(Superscript.class, (config, renderProps) -> new SuperscriptSpan(true)); + } + + @Override + public void configureVisitor(@NonNull MarkwonVisitor.Builder builder) { + builder.on(Superscript.class, new MarkwonVisitor.NodeVisitor<>() { + int depth = 0; + + @Override + public void visit(@NonNull MarkwonVisitor visitor, @NonNull Superscript superscript) { + int start = visitor.length(); + + if (!notEmptySuperscript(superscript)) { + return; + } + + if (!superscript.isBracketed()) { + visitor.builder().setSpan(new SuperscriptSpan(false), start, start + 1); // Workaround for Table Plugin + superscriptOpeningList.add(new SuperscriptOpening(superscript, start)); + return; + } + + depth++; + visitor.visitChildren(superscript); + depth--; + if (depth == 0) { + int end = visitor.builder().length(); + var spans = visitor.builder().getSpans(start, end); + for (var span : spans) { + if (span.what instanceof CodeSpan) { + if (span.end <= end) { + visitor.builder().setSpan(new SuperscriptSpan(true), start, span.start); + } + start = span.end; + } + } + if (start < end) { + visitor.setSpansForNode(superscript, start); + } + } + } + }); + } + + private boolean notEmptyLink(Link link) { + Node next = link.getFirstChild(); + while (next != null) { + if (next instanceof Text) { + return true; + } else if (next instanceof Superscript) { + if (notEmptySuperscript((Superscript) next)) { + return true; + } + } else if (next instanceof Link) { + if (notEmptyLink((Link) next)) { + return true; + } +// } else if (next instanceof SpoilerNode) { +// if (notEmptySpoilerNode((SpoilerNode) next)) { +// return true; +// } + } else { + return true; + } + next = next.getNext(); + } + + return false; + } + +// private boolean notEmptySpoilerNode(SpoilerNode spoilerNode) { +// Node next = spoilerNode.getFirstChild(); +// while (next != null) { +// if (next instanceof Text) { +// return true; +// } else if (next instanceof Superscript) { +// if (notEmptySuperscript((Superscript) next)) { +// return true; +// } +// } else if (next instanceof Link) { +// if (notEmptyLink((Link) next)) { +// return true; +// } +// } else if (next instanceof SpoilerNode) { +// if (notEmptySpoilerNode((SpoilerNode) next)) { +// return true; +// } +// } else { +// return true; +// } +// next = next.getNext(); +// } +// +// return false; +// } + + private boolean notEmptySuperscript(Superscript superscript) { + Node next; + if (superscript.isBracketed()) { + next = superscript.getFirstChild(); + } else { + next = superscript.getNext(); + } + + while (next != null) { + if (next instanceof Link) { + if (notEmptyLink((Link) next)) { + return true; + } +// } else if (next instanceof SpoilerNode) { +// if (notEmptySpoilerNode((SpoilerNode) next)) { +// return true; +// } + } else if (!(next instanceof Superscript)) { + return true; + } else { + if (notEmptySuperscript((Superscript) next)) { + return true; + } + } + next = next.getNext(); + } + + return false; + } + + @Override + public void afterRender(@NonNull Node node, @NonNull MarkwonVisitor visitor) { + superscriptOpeningBracketStorage.clear(); + } + + @Override + public void beforeSetText(@NonNull TextView textView, @NonNull Spanned markdown) { + if (superscriptOpeningList.size() == 0 || !(markdown instanceof Spannable)) { + return; + } + + var spannable = (Spannable) markdown; + var spans = getSpans(spannable, 0, spannable.length()); + final String text = spannable.toString(); + + outerLoop: + for (int i = 0; i < superscriptOpeningList.size(); i++) { + SuperscriptOpening opening = superscriptOpeningList.get(i); + SuperscriptOpening nextOpening = i + 1 < superscriptOpeningList.size() ? superscriptOpeningList.get(i + 1) : null; + + // Workaround for Table Plugin + var superscriptMarker = matchSuperscriptAtPosition(spans, opening.start); + if (superscriptMarker == null) + return; + spannable.removeSpan(superscriptMarker.what); + spans.remove(superscriptMarker); + + boolean isNextOpeningOfLocalNode = nextOpening != null && opening.node.getParent().equals(nextOpening.node.getParent()); + if (opening.start >= text.length() || (matchSpanAtPosition(spans, opening.start, CodeSpan.class) == null && Character.isWhitespace(text.charAt(opening.start))) || (isNextOpeningOfLocalNode && opening.start.equals(nextOpening.start))) { + superscriptOpeningList.remove(i); + i--; + continue; + } + + boolean isChildOfDelimited = !(opening.node.getParent() == null || opening.node.getParent() instanceof Paragraph || opening.node.getParent() instanceof TableCell); + int openingStart = opening.start; + for (int j = opening.start; j <= text.length(); j++) { + char currentChar = peek(j, text); + SpanInfo codeSpanAtPosition = matchSpanAtPosition(spans, j, CodeSpan.class); + SpanInfo nonTextSpanAtBoundary = matchNonTextSpanAtBoundary(spans, j); + // When we reach the end position of, for example, an Emphasis + // Check whether the superscript originated from inside this Emphasis + // If so, stop further spanning of the current Superscript + boolean isInsideDelimited = nonTextSpanAtBoundary != null && openingStart != j && j == nonTextSpanAtBoundary.end && (openingStart > nonTextSpanAtBoundary.start || isChildOfDelimited); + if (codeSpanAtPosition != null) { + if (openingStart < j) { + spannable.setSpan(new SuperscriptSpan(false), openingStart, j, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + //Skip to end of CodeSpan + j = codeSpanAtPosition.end; + currentChar = peek(j, text); + if (currentChar == '\0' || Character.isWhitespace(currentChar) || (isNextOpeningOfLocalNode && j == nextOpening.start) || isInsideDelimited) { + superscriptOpeningList.remove(i); + i--; + continue outerLoop; + } + openingStart = j; + } else if (currentChar == '\0' || Character.isWhitespace(currentChar) || isInsideDelimited) { + spannable.setSpan(new SuperscriptSpan(false), openingStart, j, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + superscriptOpeningList.remove(i); + i--; + continue outerLoop; + } + } + } + } + + private static class SpanInfo { + public final Object what; + public final int start; + public final int end; + public final int flags; + + private SpanInfo(Object what, int start, int end, int flags) { + this.what = what; + this.start = start; + this.end = end; + this.flags = flags; + } + } +} diff --git a/app/src/main/java/com/jerboa/util/markwon/script/SuperscriptSpan.java b/app/src/main/java/com/jerboa/util/markwon/script/SuperscriptSpan.java new file mode 100644 index 000000000..7a1c21b87 --- /dev/null +++ b/app/src/main/java/com/jerboa/util/markwon/script/SuperscriptSpan.java @@ -0,0 +1,34 @@ +package com.jerboa.util.markwon.script; + +import android.text.TextPaint; +import android.text.style.MetricAffectingSpan; + +import androidx.annotation.NonNull; + +public class SuperscriptSpan extends MetricAffectingSpan { + private static final float SCRIPT_DEF_TEXT_SIZE_RATIO = .75F; + public final boolean isBracketed; + + public SuperscriptSpan() { + this.isBracketed = false; + } + + public SuperscriptSpan(boolean isBracketed) { + this.isBracketed = isBracketed; + } + + @Override + public void updateDrawState(TextPaint tp) { + apply(tp); + } + + @Override + public void updateMeasureState(@NonNull TextPaint tp) { + apply(tp); + } + + private void apply(TextPaint paint) { + paint.setTextSize(paint.getTextSize() * SCRIPT_DEF_TEXT_SIZE_RATIO); + paint.baselineShift += (int) (paint.ascent() / 2); + } +} diff --git a/build.gradle.kts b/build.gradle.kts index ccab129da..83ceec3b3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -37,7 +37,6 @@ subprojects { ) } val configPath = "${project.projectDir.absolutePath}/../compose_compiler_config.conf" - println(configPath) freeCompilerArgs.addAll( "-P", "plugin:androidx.compose.compiler.plugins.kotlin:stabilityConfigurationPath=$configPath"