* Add `html-parser-api` and `html-parser-impl` modules
* Add `HtmlEmptyTagReplacement`
* Implement Appendable and CharSequence in SpannableBuilder
* Renamed library modules to reflect maven artifact names
* Rename `markwon-syntax` to `markwon-syntax-highlight`
* Add HtmlRenderer asbtraction
* Add CssInlineStyleParser
* Fix Theme#listItemColor and OL
* Fix task list block parser to revert parsing state when line is not matching
* Defined test format files
* image-loader add datauri parser
* image-loader add support for inline data uri image references
* Add travis configuration
* Fix image with width greater than canvas scaled
* Fix blockquote span
* Dealing with white spaces at the end of a document
* image-loader add SchemeHandler abstraction
* Add sample-latex-math module
This commit is contained in:
Dimitry 2018-09-17 13:15:58 +03:00 committed by GitHub
parent 8ef4a72131
commit e0563dca43
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
272 changed files with 23789 additions and 1899 deletions

2
.gitignore vendored
View File

@ -6,3 +6,5 @@
/captures
.externalNativeBuild
**/build
**/dist
**/node_modules

21
.travis.yml Normal file
View File

@ -0,0 +1,21 @@
# https://docs.travis-ci.com/user/languages/android/
language: android
jdk: openjdk8
sudo: false
android:
components:
- tools
- platform-tools
- tools
- build-tools-27.0.3
- android-27
branches:
except:
- gh-pages
cache:
directories:
- $HOME/.m2

206
README.md
View File

@ -4,43 +4,40 @@
[![markwon](https://img.shields.io/maven-central/v/ru.noties/markwon.svg?label=markwon)](http://search.maven.org/#search|ga|1|g%3A%22ru.noties%22%20AND%20a%3A%22markwon%22)
[![markwon-image-loader](https://img.shields.io/maven-central/v/ru.noties/markwon-image-loader.svg?label=markwon-image-loader)](http://search.maven.org/#search|ga|1|g%3A%22ru.noties%22%20AND%20a%3A%22markwon-image-loader%22)
[![markwon-syntax](https://img.shields.io/maven-central/v/ru.noties/markwon-syntax.svg?label=markwon-syntax)](http://search.maven.org/#search|ga|1|g%3A%22ru.noties%22%20AND%20a%3A%22markwon-syntax%22)
[![markwon-syntax-highlight](https://img.shields.io/maven-central/v/ru.noties/markwon-syntax-highlight.svg?label=markwon-syntax-highlight)](http://search.maven.org/#search|ga|1|g%3A%22ru.noties%22%20AND%20a%3A%22markwon-syntax-highlight%22)
[![markwon-view](https://img.shields.io/maven-central/v/ru.noties/markwon-view.svg?label=markwon-view)](http://search.maven.org/#search|ga|1|g%3A%22ru.noties%22%20AND%20a%3A%22markwon-view%22)
**Markwon** is a library for Android that renders markdown as system-native Spannables. It gives ability to display markdown in all TextView widgets (**TextView**, **Button**, **Switch**, **CheckBox**, etc), **Notifications**, **Toasts**, etc. <u>**No WebView is required**</u>. Library provides reasonable defaults for display style of markdown but also gives all the means to tweak the appearance if desired. All markdown features are supported (including limited support for inlined HTML code, markdown tables and images).
**Markwon** is a markdown library for Android. It parses markdown
following [commonmark-spec] with the help of amazing [commonmark-java]
library and renders result as _Android-native_ Spannables. **No HTML**
is involved as an intermediate step. <u>**No WebView** is required</u>.
It's extremely fast, feature-rich and extensible.
<sup>*</sup>*This file is displayed by default in the [sample-apk] application. Which is a generic markdown viewer with support to display markdown via `http`, `https` & `file` schemes and 2 themes included: Light & Dark*
It gives ability to display markdown in all TextView widgets
(**TextView**, **Button**, **Switch**, **CheckBox**, etc), **Toasts**
and all other places that accept **Spanned content**. Library provides
reasonable defaults to display style of a markdown content but also
gives all the means to tweak the appearance if desired. All markdown
features listed in [commonmark-spec] are supported
(including support for **inlined/block HTML code**, **markdown tables**,
**images** and **syntax highlight**).
[commonmark-spec]: https://spec.commonmark.org/0.28/
[commonmark-java]: https://github.com/atlassian/commonmark-java/blob/master/README.md
<sup>*</sup>*This file is displayed by default in the [sample-apk] (`markwon-sample-{latest-version}-debug.apk`) application. Which is a generic markdown viewer with support to display markdown via `http`, `https` & `file` schemes and 2 themes included: Light & Dark*
[sample-apk]: https://github.com/noties/Markwon/releases
## Installation
```groovy
implementation 'ru.noties:markwon:1.1.0'
implementation 'ru.noties:markwon-image-loader:1.1.0' // optional
implementation 'ru.noties:markwon-syntax:1.1.0' // optional
implementation 'ru.noties:markwon-view:1.1.0' // optional
implementation "ru.noties:markwon:${markwonVersion}"
implementation "ru.noties:markwon-image-loader:${markwonVersion}" // optional
implementation "ru.noties:markwon-syntax-highlight:${markwonVersion}" // optional
implementation "ru.noties:markwon-view:${markwonVersion}" // optional
```
### Snapshot
![markwon-snapshot](https://img.shields.io/nexus/s/https/oss.sonatype.org/ru.noties/markwon.svg?label=markwon)
In order to use latest `SNAPSHOT` version add snapshot repository to your root project's `build.gradle` file:
```groovy
allprojects {
repositories {
jcenter()
google()
maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' }
}
}
```
and then in your module `build.gradle`:
```groovy
implementation 'ru.noties:markwon:1.1.1-SNAPSHOT'
```
Please note that `markwon-image-loader`, `markwon-syntax` and `markwon-view` are also present in `SNAPSHOT` repository and share the same version as main `markwon` artifact.
Please visit [documentation] web-site for further reference
## Supported markdown features:
* Emphasis (`*`, `_`)
@ -48,27 +45,31 @@ Please note that `markwon-image-loader`, `markwon-syntax` and `markwon-view` are
* Strike-through (`~~`)
* Headers (`#{1,6}`)
* Links (`[]()` && `[][]`)
* Images (_requires special handling_)
* Images
* Thematic break (`---`, `***`, `___`)
* Quotes & nested quotes (`>{1,}`)
* Ordered & non-ordered lists & nested ones
* Inline code
* Code blocks
* Tables (*with limitations*)
* Small subset of inline-html (which is rendered by this library):
* * Emphasis (`<i>`, `<em>`, `<cite>`, `<dfn>`)
* * Strong emphasis (`<b>`, `<strong>`)
* * SuperScript (`<sup>`)
* * SubScript (`<sub>`)
* * Underline (`<u>`)
* * Strike-through (`<s>`, `<strike>`, `<del>`)
* other inline html is rendered via (`Html.fromHtml(...)`)
* Syntax highlight
* HTML
* Emphasis (`<i>`, `<em>`, `<cite>`, `<dfn>`)
* Strong emphasis (`<b>`, `<strong>`)
* SuperScript (`<sup>`)
* SubScript (`<sub>`)
* Underline (`<u>`, `ins`)
* Strike-through (`<s>`, `<strike>`, `<del>`)
* Link (`a`)
* Lists (`ul`, `ol`)
* Images (`img` will require configured image loader)
* Blockquote (`blockquote`)
* Heading (`h1`, `h2`, `h3`, `h4`, `h5`, `h6`)
* there is support to render any HTML tag
* Task lists:
- [ ] Not _done_
- [X] **Done** with `X`
- [x] ~~and~~ **or** small `x`
---
## Screenshots
@ -84,72 +85,11 @@ By default configuration uses TextView textColor for styling, so changing textCo
---
## Quick start
This is the most simple way to set markdown to a TextView or any of its siblings:
```java
Markwon.setMarkdown(textView, markdown);
```
## Documentation
It's just a helper method, that does underneath:
* constructs a `Parser` (see: [commonmark-java][commonmark-java]) and parses markdown
* constructs a `SpannableConfiguration`
* *renders* parsed markdown to Spannable (via `SpannableRenderer`)
* prepares TextView to display images, tables and links
* sets text
Please visit [documentation] web-site for reference
This flow answers the most simple usage of displaying markdown: one shot parsing & configuration of relatively small markdown chunks. If your markdown contains a lot of text or you plan to display multiple UI widgets with markdown you might consider *stepping in* and taking control of this flow.
The candidate requirements to *step in*:
* parsing and processing of parsed markdown in background thread
* reusing `Parser` and/or `SpannableConfiguration` between multiple calls
* ignore images and tables specific logic (you know that markdown won't contain them)
So, if we expand `Markwon.setMarkdown(textView, markdown)` method we will see the following:
```java
// create a Parser instance (can be done manually)
// internally creates default Parser instance & registers `strike-through` & `tables` extension
final Parser parser = Markwon.createParser();
// core class to display markdown, can be obtained via this method,
// which creates default instance (no images handling though),
// or via `builder` method, which lets you to configure this instance
//
// `this` refers to a Context instance
final SpannableConfiguration configuration = SpannableConfiguration.create(this);
// it's better **not** to re-use this class between multiple calls
final SpannableRenderer renderer = new SpannableRenderer();
final Node node = parser.parse(markdown);
final CharSequence text = renderer.render(configuration, node);
// for links in markdown to be clickable
textView.setMovementMethod(LinkMovementMethod.getInstance());
// we need these due to the limited nature of Spannables to invalidate TextView
Markwon.unscheduleDrawables(textView);
Markwon.unscheduleTableRows(textView);
textView.setText(text);
Markwon.scheduleDrawables(textView);
Markwon.scheduleTableRows(textView);
```
Please note that if you are having trouble with `LinkMovementMethod` you can use
`Markwon.setText(textView, markdown, movementMethod)` method (`@since 1.0.6`) to specify _no_ movement
method (aka `null`) or own implementation. As an alternative to the system `LinkMovementMethod`
you can use [Better-Link-Movement-Method][better-link-movement-method].
Please refer to [SpannableConfiguration] document for more info
## Syntax highlight
Starting with version `1.1.0` there is an artifact (`markwon-syntax`) that allows you to have syntax highlight functionality.
It is based on [Prism4j](https://github.com/noties/Prism4j) project. It contains 2 builtin themes:
`Default` (light, `Prism4jThemeDefault`) and `Darkula` (dark, `Prism4jThemeDarkula`).
[library-syntax](./library-syntax/)
[documentation]: https://noties.github.io/Markwon
---
@ -218,8 +158,6 @@ Or leave it empty and use the [link text itself].
Inline `code` has `back-ticks around` it.
<sup>*</sup>*Please note, that syntax highlighting is supported but library provides no means to do it automatically*
```javascript
var s = "JavaScript syntax highlighting";
alert(s);
@ -230,6 +168,46 @@ s = "Python syntax highlighting"
print s
```
```java
/**
* Helper method to obtain a Parser with registered strike-through &amp; table extensions
* &amp; task lists (added in 1.0.1)
*
* @return a Parser instance that is supported by this library
* @since 1.0.0
*/
@NonNull
public static Parser createParser() {
return new Parser.Builder()
.extensions(Arrays.asList(
StrikethroughExtension.create(),
TablesExtension.create(),
TaskListExtension.create()
))
.build();
}
```
```xml
<ScrollView
android:id="@+id/scroll_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="?android:attr/actionBarSize">
<TextView
android:id="@+id/text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dip"
android:lineSpacingExtra="2dip"
android:textSize="16sp"
tools:context="ru.noties.markwon.MainActivity"
tools:text="yo\nman" />
</ScrollView>
```
```
No language indicated, so no syntax highlighting.
But let's throw in a <b>tag</b>.
@ -275,16 +253,10 @@ Nested quotes
## Inline HTML
<sup>*</sup>*As Android doesn't support HTML out of box, **Markwon** library supports only a small subset of it. Everything else is rendered via `Html.fromHtml()`*
```html
<u><i>H<sup>T<sub>M</sub></sup><b><s>L</s></b></i></u>
```
* Emphasis (`<i>`, `<em>`, `<cite>`, `<dfn>`)
* Strong emphasis (`<b>`, `<strong>`)
* SuperScript (`<sup>`)
* SubScript (`<sub>`)
* Underline (`<u>`)
* Strike-through (`<s>`, `<strike>`, `<del>`)
Let's use it:
<u><i>H<sup>T<sub>M</sub></sup><b><s>L</s></b></i></u>
---
@ -330,11 +302,7 @@ Underscores (`_`)
limitations under the License.
```
[sample-apk]: https://github.com/noties/Markwon/releases/download/v1.0.0/markwon-sample-1.0.0.apk
[commonmark-java]: https://github.com/atlassian/commonmark-java/blob/master/README.md
[cheatsheet]: https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet
[SpannableConfiguration]: ./docs/SpannableConfiguration.md
[better-link-movement-method]: https://github.com/saket/Better-Link-Movement-Method
[arbitrary case-insensitive reference text]: https://www.mozilla.org
[1]: http://slashdot.org

View File

@ -2,13 +2,13 @@ apply plugin: 'com.android.application'
android {
compileSdkVersion TARGET_SDK
buildToolsVersion BUILD_TOOLS
compileSdkVersion config['compile-sdk']
buildToolsVersion config['build-tools']
defaultConfig {
applicationId "ru.noties.markwon"
minSdkVersion MIN_SDK
targetSdkVersion TARGET_SDK
minSdkVersion config['min-sdk']
targetSdkVersion config['target-sdk']
versionCode 1
versionName version
setProperty("archivesBaseName", "markwon-sample-$versionName")
@ -28,18 +28,20 @@ android {
dependencies {
implementation project(':library')
implementation project(':library-image-loader')
implementation project(':library-syntax')
implementation project(':markwon')
implementation project(':markwon-image-loader')
implementation project(':markwon-syntax-highlight')
implementation 'ru.noties:debug:3.0.0@jar'
implementation 'me.saket:better-link-movement-method:2.2.0'
deps.with {
implementation it['okhttp']
implementation it['prism4j']
implementation it['debug']
implementation it['better-link-movement']
implementation it['dagger']
}
implementation OK_HTTP
implementation 'com.google.dagger:dagger:2.10'
annotationProcessor 'com.google.dagger:dagger-compiler:2.10'
implementation PRISM_4J
annotationProcessor PRISM_4J_BUNDLER
deps['annotationProcessor'].with {
annotationProcessor it['prism4j-bundler']
annotationProcessor it['dagger-compiler']
}
}

View File

@ -8,13 +8,16 @@
android:id="@+id/scroll_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dip"
android:clipToPadding="false"
android:clipChildren="false"
android:scrollbarStyle="outsideOverlay"
android:layout_marginTop="?android:attr/actionBarSize">
<TextView
android:id="@+id/text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dip"
android:lineSpacingExtra="2dip"
android:textSize="16sp"
tools:context="ru.noties.markwon.MainActivity"

109
art/favicon.svg Normal file
View File

@ -0,0 +1,109 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="512"
height="512"
viewBox="0 0 512.00001 512.00001"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
inkscape:export-filename="/Users/di/text4169.png"
inkscape:export-xdpi="89.93"
inkscape:export-ydpi="89.93"
sodipodi:docname="favicon.svg">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.92578125"
inkscape:cx="336.05262"
inkscape:cy="186.451"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="1442"
inkscape:window-height="788"
inkscape:window-x="-1"
inkscape:window-y="0"
inkscape:window-maximized="0" />
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-540.36216)">
<rect
style="opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:0.40000001;stroke-linecap:butt;stroke-miterlimit:1;stroke-dasharray:none;stroke-opacity:0.94117647"
id="rect4194"
width="512"
height="512"
x="0"
y="540.36218"
rx="80"
ry="80"
inkscape:export-filename="/Users/di/rect4194.png"
inkscape:export-xdpi="89.93"
inkscape:export-ydpi="89.93" />
<text
xml:space="preserve"
style="font-style:normal;font-weight:normal;font-size:34.96873856px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
x="106.24741"
y="908.6958"
id="text4136"
sodipodi:linespacing="125%"><tspan
sodipodi:role="line"
id="tspan4138"
x="106.24741"
y="908.6958"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:314.71862793px;font-family:'Noto Serif';-inkscape-font-specification:'Noto Serif Bold'">M</tspan></text>
<text
xml:space="preserve"
style="font-style:normal;font-weight:normal;font-size:65.1031189px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;"
x="109.9856"
y="780.45221"
id="text4140"
sodipodi:linespacing="125%"><tspan
sodipodi:role="line"
id="tspan4142"
x="109.9856"
y="780.45221"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:293.75px;font-family:'Noto Serif';-inkscape-font-specification:'Noto Serif';fill:#666666;">**</tspan></text>
<text
xml:space="preserve"
style="font-style:normal;font-weight:normal;font-size:40.77807617px;line-height:125%;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;"
x="109.9856"
y="1150.7955"
id="text4169"
sodipodi:linespacing="125%"><tspan
sodipodi:role="line"
id="tspan4171"
x="109.9856"
y="1150.7955"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:293.75px;font-family:'Noto Serif';-inkscape-font-specification:'Noto Serif';fill:#666666;">**</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View File

@ -5,6 +5,7 @@ buildscript {
}
dependencies {
classpath 'com.android.tools.build:gradle:3.0.1'
classpath 'com.github.ben-manes:gradle-versions-plugin:0.20.0'
}
}
@ -39,25 +40,76 @@ if (hasProperty('local')) {
ext {
// Config
BUILD_TOOLS = '27.0.3'
TARGET_SDK = 27
MIN_SDK = 16
config = [
'build-tools' : '27.0.3',
'compile-sdk' : 27,
'target-sdk' : 27,
'min-sdk' : 16,
'push-aar-gradle': 'https://raw.githubusercontent.com/noties/gradle-mvn-push/master/gradle-mvn-push-aar.gradle'
]
// Dependencies
final def supportVersion = '27.1.1'
SUPPORT_ANNOTATIONS = "com.android.support:support-annotations:$supportVersion"
SUPPORT_APP_COMPAT = "com.android.support:appcompat-v7:$supportVersion"
final def commonMarkVersion = '0.11.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"
final def daggerVersion = '2.10'
ANDROID_SVG = 'com.caverock:androidsvg:1.2.1'
ANDROID_GIF = 'pl.droidsonroids.gif:android-gif-drawable:1.2.14'
OK_HTTP = 'com.squareup.okhttp3:okhttp:3.9.0'
deps = [
'support-annotations' : "com.android.support:support-annotations:$supportVersion",
'support-app-compat' : "com.android.support:appcompat-v7:$supportVersion",
'commonmark' : "com.atlassian.commonmark:commonmark:$commonMarkVersion",
'commonmark-strikethrough': "com.atlassian.commonmark:commonmark-ext-gfm-strikethrough:$commonMarkVersion",
'commonmark-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.14',
'okhttp' : 'com.squareup.okhttp3:okhttp:3.9.0',
'prism4j' : 'ru.noties:prism4j:1.1.0',
'debug' : 'ru.noties:debug:3.0.0@jar',
'better-link-movement' : 'me.saket:better-link-movement-method:2.2.0',
'dagger' : "com.google.dagger:dagger:$daggerVersion"
]
PRISM_4J = 'ru.noties:prism4j:1.1.0'
PRISM_4J_BUNDLER = 'ru.noties:prism4j-bundler:1.1.0'
deps['annotationProcessor'] = [
'prism4j-bundler': 'ru.noties:prism4j-bundler:1.1.0',
'dagger-compiler': "com.google.dagger:dagger-compiler:$daggerVersion"
]
deps['test'] = [
'junit' : 'junit:junit:4.12',
'robolectric' : 'org.robolectric:robolectric:3.8',
'ix-java' : 'com.github.akarnokd:ixjava:1.0.0',
'jackson-yaml' : 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.9.0',
'jackson-databind': 'com.fasterxml.jackson.core:jackson-databind:2.9.6',
'gson' : 'com.google.code.gson:gson:2.8.5',
'commons-io' : 'commons-io:commons-io:2.6',
'mockito' : 'org.mockito:mockito-core:2.21.0'
]
registerArtifact = this.&registerArtifact
}
task checkUpdates {
apply plugin: 'com.github.ben-manes.versions'
dependsOn 'dependencyUpdates'
}
def registerArtifact(project) {
if (hasProperty('release')) {
project.apply from: config['push-aar-gradle']
}
project.afterEvaluate {
// disable generation of BuildConfig files
project.generateDebugBuildConfig.enabled = false
project.generateReleaseBuildConfig.enabled = false
// print test status (for CI)
project.android.testOptions.unitTests.all {
testLogging {
events "passed", "skipped", "failed"
exceptionFormat "short"
showStandardStreams = true
}
}
}
}

View File

@ -0,0 +1,34 @@
<template>
<a :href="githubIssueHref" target="_blank" rel="noopener noreferrer">{{linkContent}}<OutboundLink/></a>
</template>
<script>
export default {
name: "GithubIssue",
props: {
id: {
required: true
},
displayName: { required: false },
user: {
default: "noties",
required: false
},
repo: {
default: "Markwon",
required: false
}
},
computed: {
githubIssueHref: function() {
return (
"https://github.com/" + this.user + "/" + this.repo + "/issues/" + this.id
);
},
linkContent: function() {
return this.displayName || "#" + this.id;
}
}
};
</script>

View File

@ -0,0 +1,27 @@
<template>
<a :href="githubPullHref" target="_blank" rel="noopener noreferrer">{{linkContent}}<OutboundLink/></a>
</template>
<script>
export default {
name: "GithubPull",
props: {
id: { required: true },
user: {
default: 'noties'
},
repo: {
default: 'Markwon'
}
},
computed: {
githubPullHref: function() {
return "https://github.com/" + this.user + "/" + this.repo + "/pull/" + this.id
},
linkContent: function() {
return "#" + this.id;
}
}
};
</script>

View File

@ -0,0 +1,21 @@
<template>
<a :href="githubUserProfileHref" target="_blank" rel="noopener noreferrer"><b>{{linkContent}}</b><OutboundLink/></a>
</template>
<script>
export default {
name: "GithubUser",
props: {
name: { required: true }
},
computed: {
githubUserProfileHref: function() {
return "https://github.com/" + this.name;
},
linkContent: function() {
return "@" + this.name;
}
}
};
</script>

View File

@ -0,0 +1,56 @@
<template>
<a :href="linkHref()" target="_blank" rel="noopener noreferrer">{{linkText()}}<OutboundLink/></a>
</template>
<script>
var map = {
"commonmark-spec": {
displayName: "commonmark spec",
href: "https://spec.commonmark.org/0.28/"
},
"commonmark-spec#inline": {
href: "https://spec.commonmark.org/0.28/#raw-html"
},
"commonmark-spec#block": {
href: "https://spec.commonmark.org/0.28/#html-blocks"
},
"commonmark-spec#soft-break": {
href: "https://spec.commonmark.org/0.28/#soft-line-breaks"
},
"commonmark-dingus": {
displayName: "commonmark dingus",
href: "https://spec.commonmark.org/dingus/"
},
"html-inlines": {
href: "https://developer.mozilla.org/en-US/docs/Web/HTML/Inline_elements"
},
"html-blocks": {
href: "https://developer.mozilla.org/en-US/docs/Web/HTML/Block-level_elements"
},
"jsoup": {
displayName: "Jsoup",
href: "https://github.com/jhy/jsoup/"
},
"markwon-jsoup": {
href: "https://github.com/noties/Markwon/tree/master/markwon-html-parser-impl/src/main/java/ru/noties/markwon/html/impl/jsoup"
},
"commonmark-java": {
href: "https://github.com/atlassian/commonmark-java/",
displayName: "commonmark-java"
}
};
export default {
name: "Link",
props: ["name", "displayName", "href"],
methods: {
linkHref: function() {
return this.href || map[this.name].href;
},
linkText: function() {
return this.displayName || map[this.name].displayName;
}
}
};
</script>

View File

@ -0,0 +1,19 @@
<template>
<a :href="mavenSearchUrl()"><img :src="shieldImgageUrl()" :alt="'' + artifact"></a>
</template>
<script>
export default {
name: 'MavenBadge',
props: ['artifact'],
methods: {
mavenSearchUrl: function() {
return 'http://search.maven.org/#search|ga|1|g%3A%22ru.noties%22%20AND%20a%3A%22' + this.artifact + '%22';
},
shieldImgageUrl: function() {
return 'https://img.shields.io/maven-central/v/ru.noties/' + this.artifact +'.svg?label=' + this.artifact;
}
}
}
</script>

View File

@ -0,0 +1,20 @@
<template>
<div>
<MavenBadge :artifact="'markwon'" />
<MavenBadge :artifact="'markwon-image-loader'" />
<MavenBadge :artifact="'markwon-syntax-highlight'"/>
<MavenBadge :artifact="'markwon-view'"/>
</div>
</template>
<script>
import MavenBadge from "./MavenBadge.vue";
export default {
name: "MavenBadges",
components: {
MavenBadge
}
};
</script>

View File

@ -0,0 +1,15 @@
<template>
<table>
<tr><td><b>name</b></td><td><code>{{name}}</code></td></tr>
<tr><td><b>type</b></td><td><code>{{type}}</code></td></tr>
<tr><td><b>default</b></td><td v-html="defaults"></td></tr>
</table>
</template>
<script>
export default {
name: "ThemeProperty",
props: ["name", "type", "defaults"]
};
</script>

37
docs/.vuepress/config.js Normal file
View File

@ -0,0 +1,37 @@
module.exports = {
base: '/Markwon/',
title: 'Markwon',
description: 'Android markdown library based on commonmark specification',
head: [
['link', {rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png?v=1'}],
['link', {rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-16x16.png?v=1'}],
['link', {rel: 'icon', href: '/favicon.ico?v=1'}],
['link', {rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32x32.png?v=1'}],
['link', {rel: 'manifest', href: '/manifest.json?v=1'}],
],
themeConfig: {
nav: [
{ text: 'Install', link: '/docs/install.md' },
{ text: 'Changelog', link: '/CHANGELOG.md' },
{ text: 'Github', link: 'https://github.com/noties/Markwon' }
],
sidebar: [
'/',
'/docs/getting-started.md',
'/docs/configure.md',
'/docs/theme.md',
'/docs/factory.md',
'/docs/image-loader.md',
'/docs/syntax-highlight.md',
'/docs/html.md',
'/docs/view.md'
],
sidebarDepth: 2,
lastUpdated: true
},
markdown: {
config: md => {
md.use(require('markdown-it-task-lists'));
}
}
}

View File

@ -0,0 +1,2 @@
$textColor = #000000
$accentColor = #4CAF50

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

1
docs/.vuepress/public/art Symbolic link
View File

@ -0,0 +1 @@
../../../art/

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,17 @@
{
"name": "Markwon documentation",
"short_name": "Markwon",
"icons": [
{
"src": "/android-chrome-192x192.png?v=1",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png?v=1",
"sizes": "512x512",
"type": "image/png"
}
],
"display": "standalone"
}

View File

View File

@ -1,54 +0,0 @@
# AsyncDrawable.Loader
By default this library does not render any of the images. It's done to simplify rendering of text-based markdown. But if images must be supported, then the `AsyncDrawable.Loader` can be specified whilst building a `SpannableConfiguration` instance:
```java
final AsyncDrawable.Loader loader = new AsyncDrawable.Loader() {
@Override
public void load(@NonNull String destination, @NonNull final AsyncDrawable drawable) {
// `download` method is here for demonstration purposes, it's not included in this interface
download(destination, new Callback() {
@Override
public void onDownloaded(Drawable d) {
// additionally we can call `drawable.isAttached()`
// to ensure if AsyncDrawable is in layout
drawable.setResult(d);
}
});
}
@Override
public void cancel(@NonNull String destination) {
// cancel download here
}
};
// `this` here referrs to a Context instance
final SpannableConfiguration configuration = SpannableConfiguration.builder(this)
.asyncDrawableLoader(loader)
.build();
```
There is also standalone artifact that supports image loading *out-of-box* (including support for **SVG** & **GIF**), but provides little to none configuration and could be somewhat not optimal. Please refer to the [README][mil-readme] of the module.
### Contents
* [SpannableConfiguration]
* * [SpannableTheme]
* * [AsyncDrawableLoader]
* * [SyntaxHighlight]
* * [LinkResolver]
* * [UrlProcessor]
* * [HtmlParser]
[SpannableConfiguration]: ./SpannableConfiguration.md
[SpannableTheme]: ./SpannableTheme.md
[AsyncDrawableLoader]: ./AsyncDrawableLoader.md
[SyntaxHighlight]: ./SyntaxHighlight.md
[LinkResolver]: ./LinkResolver.md
[UrlProcessor]: ./UrlProcessor.md
[HtmlParser]: ./HtmlParser.md
[mil-readme]: ../library-image-loader/README.md

72
docs/CHANGELOG.md Normal file
View File

@ -0,0 +1,72 @@
# Changelog
# 2.0.0
* Add `html-parser-api` and `html-parser-impl` modules
* Add `HtmlEmptyTagReplacement`
* Implement Appendable and CharSequence in SpannableBuilder
* Renamed library modules to reflect maven artifact names
* Rename `markwon-syntax` to `markwon-syntax-highlight`
* Add HtmlRenderer asbtraction
* Add CssInlineStyleParser
* Fix Theme#listItemColor and OL
* Fix task list block parser to revert parsing state when line is not matching
* Defined test format files
* image-loader add datauri parser
* image-loader add support for inline data uri image references
* Add travis configuration
* Fix image with width greater than canvas scaled
* Fix blockquote span
* Dealing with white spaces at the end of a document
* image-loader add SchemeHandler abstraction
* Add sample-latex-math module
## v1.1.1
* Fix OrderedListItemSpan text position (baseline) (<GithubIssue id="55" />)
* Add softBreakAddsNewLine option for SpannableConfiguration (<GithubIssue id="54" />)
* Paragraph text can now explicitly be spanned (<GithubPull id="58" />)<br>Thanks to <GithubUser name="c-b-h" />
* Fix table border color if odd background is specified (<GithubIssue id="56" />)
* Add table customizations (even and header rows)
## v1.1.0
* Update commonmark to 0.11.0 and android-gif to 1.2.14
* Add syntax highlight functionality (`library-syntax` module and `markwon-syntax` artifact)
* Add headingTypeface, headingTextSizes to SpannableTheme<br>Thanks to <GithubUser name="edenman" />
* Introduce `MediaDecoder` abstraction to `image-loader` module
* Introduce `SpannableFactory`<br>Thanks for idea to <GithubUser name="c-b-h" />
* Update sample application to use syntax-highlight
* Update sample application to use clickable placeholder for GIF media
## v1.0.6
* Fix bullet list item size (depend on text size and not top-bottom arguments)
* Add ability to specify MovementMethod when applying markdown to a TextView
* Markdown images size is also resolved via ImageSizeResolver
* Moved `ImageSize`, `ImageSizeResolver` and `ImageSizeResolverDef`
to `ru.noties.markwon.renderer` package (one level up, previously `ru.noties.markwon.renderer.html`)
## v1.0.5
* Change LinkSpan to extend URLSpan. Allow default linkColor (if not set explicitly)
* Fit an image without dimensions to canvas width (and keep ratio)
* Add support for separate color for code blocks (<GithubPull id="37" />)<br>Thanks to <GithubUser name="Arcnor" />
## v1.0.4
* Fixes <GithubIssue id="28"/> (tables are not rendered when at the end of the markdown)
* Adds support for `indented code blocks`<br>Thanks to <GithubUser name="dlew"/>
## v1.0.3
* Fixed ordered lists (when number width is greater than block margin)
## v1.0.2
* Fixed additional white spaces at the end of parsed markdown
* Fixed headings with no underline (levels 1 &amp; 2)
* Tables can have no borders
## v1.0.1
* Support for task-lists (<GithubIssue id="2" />)
* Spans now are applied in reverse order (<GithubIssue id="5" /> <GithubIssue id="10" />)
* Added `SpannableBuilder` to follow the reverse order of spans
* Updated `commonmark-java` to `0.10.0`
* Fixes <GithubIssue id="1" />
## v1.0.0
Initial release

View File

@ -1,60 +0,0 @@
# HtmlParser
As markdown supports HTML to be inlined, we need to introduce another entity that does (limited) parsing. Obtain an instance of `SpannableHtmlParser` via one of these factory methods:
```java
SpannableHtmlParser.create(SpannableTheme, AsyncDrawable.Loader)
SpannableHtmlParser.create(SpannableTheme, AsyncDrawable.Loader, UrlProcessor, LinkSpan.Resolver)
```
Or, if further tweaking is requered builder methods:
```java
// creates empty builder
SpannableHtmlParser.builder();
// creates builder that is set-up to default values
SpannableHtmlParser.builderWithDefaults(
@NonNull SpannableTheme theme,
@Nullable AsyncDrawable.Loader asyncDrawableLoader,
@Nullable UrlProcessor urlProcessor,
@Nullable LinkSpan.Resolver resolver
)
```
Builder with defaults additionally handles these HTML tags:
* `b`, `strong`
* `i`, `em`, `cite`, `dfn`
* `sup`
* `sub`
* `u`
* `del`, `s`, `strike`
* `a`
* `img` (only if `AsyncDrawable.Loader` was provided)
You can add own simple tags handling (or override default) via:
```java
SpannableHtmlParser.Builder.simpleTag(String, SpanProvider)
```
Please note, that not all tags are possible to handle via this. These are so called `void` tags ([link](https://www.w3.org/TR/html51/syntax.html#void-elements)) and so-called `html-blocks` ([link](http://spec.commonmark.org/0.18/#html-blocks)). An exception is made only for `img` tag -> it's possible to handle it via `imageProvider` property in `Builder`
### Contents
* [SpannableConfiguration]
* * [SpannableTheme]
* * [AsyncDrawableLoader]
* * [SyntaxHighlight]
* * [LinkResolver]
* * [UrlProcessor]
* * [HtmlParser]
[SpannableConfiguration]: ./SpannableConfiguration.md
[SpannableTheme]: ./SpannableTheme.md
[AsyncDrawableLoader]: ./AsyncDrawableLoader.md
[SyntaxHighlight]: ./SyntaxHighlight.md
[LinkResolver]: ./LinkResolver.md
[UrlProcessor]: ./UrlProcessor.md
[HtmlParser]: ./HtmlParser.md

View File

@ -1,28 +0,0 @@
# LinkResolver
Link resolver is used to navigate to clicked link. By default `LinkResolverDef` is used and it just constructs an `Intent` and launches activity that can handle it, or silently fails if activity cannot be resolved. The main interface:
```java
public interface Resolver {
void resolve(View view, @NonNull String link);
}
```
### Contents
* [SpannableConfiguration]
* * [SpannableTheme]
* * [AsyncDrawableLoader]
* * [SyntaxHighlight]
* * [LinkResolver]
* * [UrlProcessor]
* * [HtmlParser]
[SpannableConfiguration]: ./SpannableConfiguration.md
[SpannableTheme]: ./SpannableTheme.md
[AsyncDrawableLoader]: ./AsyncDrawableLoader.md
[SyntaxHighlight]: ./SyntaxHighlight.md
[LinkResolver]: ./LinkResolver.md
[UrlProcessor]: ./UrlProcessor.md
[HtmlParser]: ./HtmlParser.md

70
docs/README.md Normal file
View File

@ -0,0 +1,70 @@
---
title: 'Overview'
---
<img :src="$withBase('./art/markwon_logo.png')" alt="Markwon Logo" width="50%">
<br><br>
<MavenBadges/>
**Markwon** is a markdown library for Android. It parses markdown following
<Link name="commonmark-spec" /> with the help of amazing <Link name="commonmark-java" /> library
and renders result as _Android-native_ Spannables. **No HTML** is involved
as an intermediate step. <u>**No WebView** is required</u>. It's extremely fast,
feature-rich and extensible.
It gives ability to display markdown in all TextView widgets (**TextView**,
**Button**, **Switch**, **CheckBox**, etc), **Toasts** and all other places that accept
**Spanned content**. Library provides reasonable defaults to display style of a markdown content
but also gives all the means to tweak the appearance if desired. All markdown features
listed in <Link name="commonmark-spec" /> are supported (including support for **inlined/block HTML code**,
**markdown tables**, **images** and **syntax highlight**).
## Supported markdown features:
* Emphasis (`*`, `_`)
* Strong emphasis (`**`, `__`)
* Strike-through (`~~`)
* Headers (`#{1,6}`)
* Links (`[]()` && `[][]`)
* [Images](/docs/image-loader.md)
* Thematic break (`---`, `***`, `___`)
* Quotes & nested quotes (`>{1,}`)
* Ordered & non-ordered lists & nested ones
* Inline code
* Code blocks
* Tables (*with limitations*)
* [Syntax highlight](/docs/syntax-highlight.md)
* [HTML](/docs/html.md)
* Emphasis (`<i>`, `<em>`, `<cite>`, `<dfn>`)
* Strong emphasis (`<b>`, `<strong>`)
* SuperScript (`<sup>`)
* SubScript (`<sub>`)
* Underline (`<u>`, `ins`)
* Strike-through (`<s>`, `<strike>`, `<del>`)
* Link (`a`)
* Lists (`ul`, `ol`)
* Images (`img` will require configured image loader)
* Blockquote (`blockquote`)
* Heading (`h1`, `h2`, `h3`, `h4`, `h5`, `h6`)
* there is support to render any HTML tag, but it will require to create a special `TagHandler`,
more information can be found in [HTML section](/docs/html.md#custom-tag-handler)
* Task lists:
- [ ] Not _done_
- [X] **Done** with `X`
- [x] ~~and~~ **or** small `x`
## Screenshots
<img :src="$withBase('/art/mw_light_01.png')" alt="screenshot light #1" width="30%">
<img :src="$withBase('/art/mw_light_02.png')" alt="screenshot light #2" width="30%">
<img :src="$withBase('/art/mw_light_03.png')" alt="screenshot light #3" width="30%">
<img :src="$withBase('/art/mw_dark_01.png')" alt="screenshot dark #2" width="30%">
By default configuration uses TextView textColor for styling, so changing textColor changes style
:::tip Sample application
Screenshots are taken from sample application. It is a generic markdown viewer
with support to display markdown content via `http`, `https` &amp; `file` schemes
and 2 themes included: Light &amp; Dark. It can be downloaded from [releases](https://github.com/noties/Markwon/releases)
:::

View File

@ -1,43 +0,0 @@
# SpannableConfiguration
In order to render correctly markdown, this library needs a `SpannableConfiguration` instance. It has 2 factory methods:
```java
// creates default instance
SpannableConfiguration.create(Context);
// returns configurable Builder
SpannableConfiguration.builder(Context);
```
`SpannableConfiguration.Builder` class has these configurable properties (which are described in more detail further):
```java
public Builder theme(SpannableTheme theme);
public Builder asyncDrawableLoader(AsyncDrawable.Loader asyncDrawableLoader);
public Builder syntaxHighlight(SyntaxHighlight syntaxHighlight);
public Builder linkResolver(LinkSpan.Resolver linkResolver);
public Builder urlProcessor(UrlProcessor urlProcessor);
public Builder htmlParser(SpannableHtmlParser htmlParser);
// and obviously:
public SpannableConfiguration build();
```
### Contents
* [SpannableConfiguration]
* * [SpannableTheme]
* * [AsyncDrawableLoader]
* * [SyntaxHighlight]
* * [LinkResolver]
* * [UrlProcessor]
* * [HtmlParser]
[SpannableConfiguration]: ./SpannableConfiguration.md
[SpannableTheme]: ./SpannableTheme.md
[AsyncDrawableLoader]: ./AsyncDrawableLoader.md
[SyntaxHighlight]: ./SyntaxHighlight.md
[LinkResolver]: ./LinkResolver.md
[UrlProcessor]: ./UrlProcessor.md
[HtmlParser]: ./HtmlParser.md

View File

@ -1,136 +0,0 @@
# SpannableTheme
`SpannableTheme` controls the appearance of rendered markdown. It has pretty reasonable defaults, which are established based on style of a TextView to which it is applied. It has some factory methods:
```java
// creates ready-to-use SpannableThemeObject
SpannableTheme.create(Context);
// can be used to tweak default appearance
SpannableTheme.builderWithDefaults(Context);
// returns empty builder (no default values are set)
SpannableTheme.builder();
// returns a builder that is instantiated with all values from specified SpannableTheme
SpannableTheme.builder(SpannableTheme copyFrom);
```
`SpannableTheme.Builder` have these configurations:
#### Link
```java
public Builder linkColor(@ColorInt int linkColor);
```
#### Block
```java
// left margin for: lists & quotes (text is shifted)
public Builder blockMargin(@Dimension int blockMargin);
```
#### Quote
```java
// width of quote indication (the `|`)
public Builder blockQuoteWidth(@Dimension int blockQuoteWidth);
// color of `|` quote indication
public Builder blockQuoteColor(@ColorInt int blockQuoteColor);
```
#### Lists
```java
// color of list item bullets(●, ○, ■)/numbers
public Builder listItemColor(@ColorInt int listItemColor);
// stroke width for list bullet (2nd level - `○`)
public Builder bulletListItemStrokeWidth(@Dimension int bulletListItemStrokeWidth);
// width of list bullet (●, ○, ■)
public Builder bulletWidth(@Dimension int bulletWidth);
```
#### Code
```java
// text color for `code` blocks
public Builder codeTextColor(@ColorInt int codeTextColor);
// background color for `code` blocks
public Builder codeBackgroundColor(@ColorInt int codeBackgroundColor);
// left margin for multiline `code` blocks
public Builder codeMultilineMargin(@Dimension int codeMultilineMargin);
// typeface of `code` block
public Builder codeTypeface(@NonNull Typeface codeTypeface);
// text size for `code` block
public Builder codeTextSize(@Dimension int codeTextSize);
```
#### Headings
```java
// height of the `break` line under h1 & h2
public Builder headingBreakHeight(@Dimension int headingBreakHeight);
// color of the `break` line under h1 & h2
public Builder headingBreakColor(@ColorInt int headingBreakColor);
```
#### SuperScript & SupScript
```java
// ratio for <sup> & <sub> text size (calculated based on TextView text size)
public Builder scriptTextSizeRatio(@FloatRange(from = .0F, to = Float.MAX_VALUE) float scriptTextSizeRatio);
```
#### Thematic break
```java
// the `---` thematic break color
public Builder thematicBreakColor(@ColorInt int thematicBreakColor);
// the `---` thematic break height
public Builder thematicBreakHeight(@Dimension int thematicBreakHeight);
```
#### Tables
```java
// padding inside a table cell
public Builder tableCellPadding(@Dimension int tableCellPadding);
// color of table borders
public Builder tableBorderColor(@ColorInt int tableBorderColor);
// the `stroke` width of table border
public Builder tableBorderWidth(@Dimension int tableBorderWidth);
// the background of odd table rows
public Builder tableOddRowBackgroundColor(@ColorInt int tableOddRowBackgroundColor);
```
#### Task lists
Task lists are supported but with some limitations. First of all, task list cannot be nested
(in a list, quote, etc). By default (if used factory method `builderWithDefaults`) TaskListDrawable
will be used with `linkColor` as the primary color and `windowBackground` as the checkMarkColor.
```java
public Builder taskListDrawable(@NonNull Drawable taskListDrawable);
```
### Contents
* [SpannableConfiguration]
* * [SpannableTheme]
* * [AsyncDrawableLoader]
* * [SyntaxHighlight]
* * [LinkResolver]
* * [UrlProcessor]
* * [HtmlParser]
[SpannableConfiguration]: ./SpannableConfiguration.md
[SpannableTheme]: ./SpannableTheme.md
[AsyncDrawableLoader]: ./AsyncDrawableLoader.md
[SyntaxHighlight]: ./SyntaxHighlight.md
[LinkResolver]: ./LinkResolver.md
[UrlProcessor]: ./UrlProcessor.md
[HtmlParser]: ./HtmlParser.md

View File

@ -1,37 +0,0 @@
# Syntax highlight
This library does not provide ready-to-be-used implementation of syntax highlight, but it can be added via `SyntaxHighlight` interface whilst building `SpannableConfiguration`:
```java
final SyntaxHighlight syntaxHighlight = new SyntaxHighlight() {
@NonNull
@Override
public CharSequence highlight(@Nullable String info, @NonNull String code) {
// create Spanned of highlight here
return null; // must not return `null` here
}
};
final SpannableConfiguration configuration = SpannableConfiguration.builder(this)
.syntaxHighlight(syntaxHighlight)
.build();
```
### Contents
* [SpannableConfiguration]
* * [SpannableTheme]
* * [AsyncDrawableLoader]
* * [SyntaxHighlight]
* * [LinkResolver]
* * [UrlProcessor]
* * [HtmlParser]
[SpannableConfiguration]: ./SpannableConfiguration.md
[SpannableTheme]: ./SpannableTheme.md
[AsyncDrawableLoader]: ./AsyncDrawableLoader.md
[SyntaxHighlight]: ./SyntaxHighlight.md
[LinkResolver]: ./LinkResolver.md
[UrlProcessor]: ./UrlProcessor.md
[HtmlParser]: ./HtmlParser.md

View File

@ -1,40 +0,0 @@
# UrlProcessor
If you wish to process urls (links & images) that markdown contains, the `UrlProcessor` can be used:
```java
final UrlProcessor urlProcessor = new UrlProcessor() {
@NonNull
@Override
public String process(@NonNull String destination) {
// modify the `destination` or return as-is
return null;
}
};
final SpannableConfiguration configuration = SpannableConfiguration.builder(this)
.urlProcessor(urlProcessor)
.build();
```
The primary goal of additing this abstraction is to give ability to convert relative urls to absolute ones. If it fits your purpose, then `UrlProcessorRelativeToAbsolute` can be used:
```java
final UrlProcessor urlProcessor = new UrlProcessorRelativeToAbsolute("https://this-is-base.org");
```
### Contents
* [SpannableConfiguration]
* * [SpannableTheme]
* * [AsyncDrawableLoader]
* * [SyntaxHighlight]
* * [LinkResolver]
* * [UrlProcessor]
* * [HtmlParser]
[SpannableConfiguration]: ./SpannableConfiguration.md
[SpannableTheme]: ./SpannableTheme.md
[AsyncDrawableLoader]: ./AsyncDrawableLoader.md
[SyntaxHighlight]: ./SyntaxHighlight.md
[LinkResolver]: ./LinkResolver.md
[UrlProcessor]: ./UrlProcessor.md
[HtmlParser]: ./HtmlParser.md

19
docs/deploy.sh Executable file
View File

@ -0,0 +1,19 @@
#!/usr/bin/env sh
# abort on errors
set -e
# build
npm run docs:build
# navigate into the build output directory
cd .vuepress/dist
git init
git add -A
git commit -m 'deploy'
# if you are deploying to https://<USERNAME>.github.io/<REPO>
git push -f git@github.com:noties/Markwon.git master:gh-pages
cd -

226
docs/docs/configure.md Normal file
View File

@ -0,0 +1,226 @@
# Configuration
`SpannableConfiguration` is the core component that controls how markdown is parsed and rendered.
It can be obtained via factory methods:
```java
// creates default implementation
final SpannableConfiguration configuration = SpannableConfiguration.create(context);
```
```java
// creates configurablable instance via `#builder` method
final SpannableConfiguration configuration = SpannableConfiguration.builder(context)
.asyncDrawableLoader(AsyncDrawableLoader.create())
.build();
```
:::tip Note
If `#builder` factory method is used, you do not need to specify default
values as they will be applied automatically
:::
:::warning Images
If you plan on using images inside your markdown/HTML, you will have to **explicitly**
register an implementation of `AsyncDrawable.Loader` via `#asyncDrawableLoader` builder method.
`Markwon` comes with ready implementation for that and it can be found in
`markwon-image-loader` module. Refer to module [documentation](/docs/image-loader.md)
:::
## Theme
`SpannableTheme` controls how markdown is rendered. It has pretty extensive number of
options that can be found [here](/docs/theme.md)
```java
SpannableConfiguration.builder(context)
.theme(SpannableTheme)
.build();
```
If `SpannableTheme` is not provided explicitly, `SpannableTheme.create(context)` will be used
## Images
### Async loader
`AsyncDrawable.Loader` handles images in your markdown and HTML
```java
SpannableConfiguration.builder(context)
.asyncDrawableLoader(AsyncDrawable.Loader)
.build();
```
If `AsyncDrawable.Loader` is not provided explicitly, default **no-op** implementation will be used.
:::tip Implementation
There are no restrictions on what implementation to use, but `Markwon` has artifact that can
answer the most common needs of displaying SVG, GIF and other image formats. It can be found [here](/docs/image-loader.md)
:::
### Size resolver <Badge text="1.0.1" />
`ImageSizeResolver` controls the size of an image to be displayed. Currently it
handles only HTML images (specified via `img` tag).
```java
SpannableConfiguration.builder(context)
.imageSizeResolver(ImageSizeResolver)
.build();
```
If not provided explicitly, default `ImageSizeResolverDef` implementation will be used.
It handles 3 dimention units:
* `%` (percent)
* `em` (relative to text size)
* `px` (absolute size, every dimention that is not `%` or `em` is considered to be _absolute_)
```html
<img width="100%">
<img width="2em" height="10px">
<img style="{width: 100%; height: 8em;}">
```
`ImageSizeResolverDef` keeps the ratio of original image if one of the dimentions is missing.
:::warning Height%
There is no support for `%` units for `height` dimention. This is due to the fact that
height of an TextView in which markdown is displayed is non-stable and changes with time
(for example when image is loaded and applied to a TextView it will _increase_ TextView's height),
so we will have no point-of-refence from which to _calculate_ image height.
:::
## Syntax highlight
`SyntaxHighlight` controls the syntax highlight for code blocks (in markdown).
```java
SpannableConfiguration.builder(context)
.syntaxHighlight(SyntaxHighlight)
.build();
```
If not provided explicitly, default **no-op** implementation will be used.
:::tip Syntax highlight
Although `SyntaxHighlight` interface was included with the very first version
of `Markwon` there were no ready-to-use implementations. But starting with <Badge text="1.1.0" />
`Markwon` provides one. It can be found in `markwon-syntax-highlight` artifact. Refer
to module [documentation](/docs/syntax-highlight.md)
:::
## Link resolver
`LinkSpan.Resolver` is triggered when a link is clicked in markdown/HTML.
```java
SpannableConfiguration.builder(context)
.linkResolver(LinkSpan.Resolver)
.build();
```
If not provided explicitly, default `LinkResolverDef` implementation will be used.
Underneath it constructs an `Intent` and _tries_ to start an Activity associated with it.
It no Activity is found, it will silently fail (no runtime exceptions)
## URL processor
`UrlProcessor` is used to process found URLs in markdown/HTML.
```java
SpannableConfiguration.builder(context)
.urlProcessor(UrlProcessor)
.build();
```
If not provided explicitly, default **no-op** implementation will be used.
`Markwon` provides 2 implementations of `UrlProcessor`:
* `UrlProcessorRelativeToAbsolute`
* `UrlProcessorAndroidAssets`
### UrlProcessorRelativeToAbsolute
`UrlProcessorRelativeToAbsolute` can be used to make relative URL absolute. For example if an image is
defined like this: `![img](./art/image.JPG)` and `UrlProcessorRelativeToAbsolute`
is created with `https://github.com/noties/Markwon/raw/master/` as the base:
`new UrlProcessorRelativeToAbsolute("https://github.com/noties/Markwon/raw/master/")`,
then final image will have `https://github.com/noties/Markwon/raw/master/art/image.JPG`
as the destination.
### UrlProcessorAndroidAssets
`UrlProcessorAndroidAssets` can be used to make processed links to point to Android assets folder.
So an image: `![img](./art/image.JPG)` will have `file:///android_asset/art/image.JPG` as the
destination
## Factory <Badge text="1.1.0" />
`SpannableFactory` is used to control _what_ span implementations to be used
```java
SpannableConfiguration.builder(context)
.factory(SpannableFactory)
.build();
```
If not provided explicitly, default `SpannableFactoryDef` implementation will be used. It is documented
in [this section](/docs/factory.md)
## Soft line break <Badge text="1.1.1" />
`softBreakAddsNewLine` option controls how _soft breaks_ are treated in the final result.
If `true` -> soft break will add a new line, else it will add a ` ` (space) char.
```java
SpannableConfiguration.builder(context)
.softBreakAddsNewLine(boolean)
.build();
```
If not provided explicitly, default `false` value will be used.
<Link name="commonmark-spec#soft-break" displayName="Commonmark specification" />
## HTML <Badge text="2.0.0" />
### Parser
`MarkwonHtmlParser` is used to parse HTML content
```java
SpannableConfiguration.builder(context)
.htmlParser(MarkwonHtmlParser)
.build();
```
if not provided explicitly, default `MarkwonHtmlParserImpl` will be used
**if** it can be found in classpath, otherwise default **no-op** implementation
wiil be used. Refer to [HTML](/docs/html.md#parser) document for more information about this behavior.
### Renderer
`MarkwonHtmlRenderer` controls how parsed HTML content will be rendered.
```java
SpannableConfiguration.builder(context)
.htmlRenderer(MarkwonHtmlRenderer)
.build();
```
If not provided explicitly, default `MarkwonHtmlRenderer` implementation will be used.
It is documented [here](/docs/html.md#renderer)
### HTML allow non-closed tags
`htmlAllowNonClosedTags` option is used to control whether or not to
render non-closed HTML tags
```java
SpannableConfiguration.builder(context)
.htmlAllowNonClosedTags(boolean)
.build();
```
If not provided explicitly, default value `false` will be used (non-closed tags **won't** be rendered).

61
docs/docs/factory.md Normal file
View File

@ -0,0 +1,61 @@
# Factory <Badge text="1.1.0" />
`SpannableFactory` is used to create Span implementations.
```java
SpannableConfiguration.builder(context)
.factory(SpannableFactory)
.build();
```
`Markwon` provides default `SpannableFactoryDef` implementation that is
used by default.
Spans:
* `strongEmphasis`
* `emphasis`
* `blockQuote`
* `code`
* `orderedListItem`
* `bulletListItem`
* `thematicBreak`
* `heading`
* `strikethrough`
* `taskListItem`
* `tableRow`
* `paragraph` <Badge text="1.1.1" />
* `image`
* `link`
* `superScript` (HTML content only)
* `subScript` (HTML content only)
* `underline` (HTML content only)
:::tip
`SpannableFactory` can be used to ignore some kinds of text markup. If, for example,
you do not wish to apply _emphasis_ styling to your final result, just return `null`
from `emphasis` factory method:
```java
@Nullable
@Override
public Object emphasis() {
return null;
}
```
:::
:::tip
All factory methods in `SpannableFactory` return an `Object`, but you can actually
return an **array of Objects** if you wish to apply multiple Spans to a single styling node.
For example, let's make all _emphasis_ also <span :style="{color: '#F00'}">red</span>:
```java
@Nullable
@Override
public Object emphasis() {
return new Object[] {
super.emphasis(),
new ForegroundColorSpan(Color.RED)
};
}
```
:::

View File

@ -0,0 +1,97 @@
# Getting started
:::tip Installation
Please follow [installation](/docs/install.md) instructions
to learn how to add `Markwon` to your project
:::
## Quick one
This is the most simple way to set markdown to a `TextView` or any of its siblings:
```java
Markwon.setMarkdown(textView, "**Hello there!**");
```
The most simple way to obtain markdown to be applied _somewhere_ else:
```java
// parsed and styled markdown
final CharSequence markdown = Markwon.markdown(context, "**Hello there!**");
// use it
Toast.makeText(context, markdown, Toast.LENGTH_LONG).show();
```
## Longer one
When you need to customize markdown parsing/rendering you can use [SpannableConfiguration](/docs/configure.md):
```java
final SpannableConfiguration configuration = SpannableConfiguration.builder(context)
.asyncDrawableLoader(AsyncDrawableLoader.create())
.build();
Markwon.setMarkdown(textView, configuration, "Are **you** still there?");
final CharSequence markdown = Markwon.markdown(configuration, "Are **you** still there?");
Toast.makeText(context, markdown, Toast.LENGTH_LONG).show();
```
## No magic one
In order to understand how previous examples work, let's break them down:
* construct a `Parser` (see: <Link name="commonmark-java" />) and parse markdown
* construct a `SpannableConfiguration` (if it's not provided)
* *render* parsed markdown to Spannable (via `SpannableRenderer`)
* prepares TextView to display images, tables and links
* sets text
This flow answers the most simple usage of displaying markdown: one shot parsing
&amp; configuration of relatively small markdown chunks. If your markdown contains
a lot of text or you plan to display multiple UI widgets with markdown you might
consider *stepping in* and taking control of this flow.
The candidate requirements to *step in*:
* parsing and processing of parsed markdown in a background thread
* reusing `Parser` and/or `SpannableConfiguration` between multiple calls
* ignore images or tables specific logic (you know that markdown won't contain them)
So, if we expand `Markwon.setMarkdown(textView, markdown)` method we will see the following:
```java
// create a Parser instance (can be done manually)
// internally creates default Parser instance & registers `strike-through` & `tables` extension
final Parser parser = Markwon.createParser();
// core class to display markdown, can be obtained via this method,
// which creates default instance (no images handling though),
// or via `builder` method, which lets you to configure this instance
final SpannableConfiguration configuration = SpannableConfiguration.create(context);
final SpannableRenderer renderer = new SpannableRenderer();
final Node node = parser.parse(markdown);
final CharSequence text = renderer.render(configuration, node);
// for links in markdown to be clickable
textView.setMovementMethod(LinkMovementMethod.getInstance());
// we need these due to the limited nature of Spannables to invalidate TextView
Markwon.unscheduleDrawables(textView);
Markwon.unscheduleTableRows(textView);
textView.setText(text);
Markwon.scheduleDrawables(textView);
Markwon.scheduleTableRows(textView);
```
:::tip Note
If you are having trouble with `LinkMovementMethod` you can use
`Markwon.setText(textView, markdown, movementMethod)` method <Badge text="1.0.6" /> to specify _no_ movement
method (aka `null`) or own implementation. As an alternative to the system `LinkMovementMethod`
you can use [Better-Link-Movement-Method](https://github.com/saket/Better-Link-Movement-Method).
Please note that `Markwon.setText` method expects _parsed_ markdown as the second argument.
:::

303
docs/docs/html.md Normal file
View File

@ -0,0 +1,303 @@
# HTML <Badge text="2.0.0" />
Starting with version `2.0.0` `Markwon` brings the whole HTML parsing/rendering
stack _on-site_. The main reason for this are _special_ definitions of HTML nodes
by <Link name="commonmark-spec" />. More specifically: <Link name="commonmark-spec#inline" displayName="inline" />
and <Link name="commonmark-spec#block" displayName="block" />.
These two are _a bit_ different from _native_ HTML understanding.
Well, they are _completely_ different and share only the same names as
<Link name="html-inlines" displayName="HTML-inline"/> and <Link name="html-blocks" displayName="HTML-block"/>
elements. This leads to situations when for example an `<i>` tag is considered
a block when it's used like this:
```markdown
<i>
Hello from italics tag
</i>
```
:::tip A bit of background
<br>
<GithubIssue id="52" displayName="This issue" /> had brought attention to differences between HTML &amp; commonmark implementations. <br><br>
:::
Let's modify code snippet above _a bit_:
```markdown{3}
<i>
Hello from italics tag
</i>
```
We have just added a `new-line` before closing `</i>` tag. And this
changes everything as now, according to the <Link name="commonmark-dingus" />,
we have 2 HtmlBlocks: one before `new-line` (containing open `<i>` tag and text content)
and one after (containing as little as closing `</i>` tag).
If we modify code snippet _a bit_ again:
```markdown{4}
<i>
Hello from italics tag
</i><b>bold></b>
```
We will have 1 HtmlBlock (from previous snippet) and a bunch of HtmlInlines:
* HtmlInline (`<i>`)
* HtmlInline (`<b>`)
* Text (`bold`)
* HtmlInline (`</b>`)
Those _little_ differences render `Html.fromHtml` (which was used in `1.x.x` versions)
useless. And actually it renders most of the HTML parsers implementations useless,
as most of them do not allow processing of HTML fragments in a raw fashion
without _fixing_ content on-the-fly.
Both `TagSoup` and `Jsoup` HTML parsers (that were considered for this project) are built to deal with
_malicious_ HTML code (*all HTML code*? :no_mouth:). So, when supplied
with a `<i>italic` fragment they will make it `<i>italic</i>`.
And it's a good thing, but consider these fragments for the sake of markdown:
* `<i>italic `
* `<b>bold italic`
* `</b><i>`
We will get:
* `<i>italic </i>`
* `<b>bold italic</b>`
_<sup>*</sup> Or to be precise: `<html><head></head><body><i>italic </i></body></html>` &amp;
`<html><head></head><body><b>bold italic</b></body></html>`_
Which will be rendered in a final document:
|expected|actual|
|---|---|
|<i>italic <b>bold italic</b></i>|<i>italic </i><b>bold italic</b>|
This might seem like a minor problem, but add more tags to a document,
introduce some deeply nested structures, spice openning and closing tags up
by adding markdown markup between them and finally write _malicious_ HTML code :laughing:!
There is no such problem on the _frontend_ for which commonmark specification is mostly
aimed as _frontend_ runs in a web-browser environment. After all _parsed_ markdown
will become HTML tags (most common usage). And web-browser will know how to render final result.
We, on the other hand, do not posess HTML heritage (*thank :robot:!*), but still
want to display some HTML to style resulting markdown a bit. That's why `Markwon`
incorporated own HTML parsing logic. It is based on the <Link name="jsoup" /> project.
And makes usage of the `Tokekiser` class that allows to _tokenise_ input HTML.
All other code that doesn't follow this purpose was removed. It's safe to use
in projects that already have `jsoup` dependency as `Markwon` repackaged **jsoup** source classes
(which could be found <Link name="markwon-jsoup" displayName="here"/>)
## Parser
There are no additional steps to configure HTML parsing. It's enabled by default.
If you wish to _exclude_ it, please follow the [exclude](#exclude-html-parsing) section below.
The key class here is: `MarkwonHtmlParser` that is defined in `markwon-html-parser-api` module.
`markwon-html-parser-api` is a simple module that defines HTML parsing contract and
does not provide implementation.
To change what implementation `Markwon` should use, `SpannableConfiguration` can be used:
```java{2}
SpannableConfiguration.builder(context)
.htmlParser(MarkwonHtmlParser)
.build();
```
`markwon-html-parser-impl` on the other hand provides `MarkwonHtmlParser` implementation.
It's called `MarkwonHtmlParserImpl`. It can be created like this:
```java
final MarkwonHtmlParser htmlParser = MarkwonHtmlParserImpl.create();
// or
final MarkwonHtmlParser htmlParser = MarkwonHtmlParserImpl.create(HtmlEmptyTagReplacement);
```
### Empty tag replacement
In order to append text content for self-closing, void or just _empty_ HTML tags,
`HtmlEmptyTagReplacement` can be used. As we cannot set Span for empty content,
we must represent empty tag with text during parsing stage (if we want it to be represented).
Consider this:
* `<img src="me-sad.JPG">`
* `<br />`
* `<who-am-i></who-am-i>`
By default (`HtmlEmptyTagReplacement.create()`) will handle `img` and `br` tags.
`img` will be replaced with `alt` property if it is present and `\uFFFC` if it is not.
And `br` will insert a new line.
### Non-closed tags
It's possible that your HTML can contain non-closed tags. By default `Markwon` will ignore them,
but if you wish to get a bit closer to a web-browser experience, you can allow this behaviour:
```java{2}
SpannableConfiguration.builder(context)
.htmlAllowNonClosedTags(true)
.build();
```
:::warning Note
If there is (for example) an `<i>` tag at the start of a document and it's not closed
and `Markwon` is configured to **not** ignore non-closed tags (`.htmlAllowNonClosedTags(true)`),
it will make the whole document in italics
:::
### Implementation note
`MarkwonHtmlParserImpl` does not create a unified HTML node. Instead it creates
2 collections: inline tags and block tags. Inline tags are represented as a `List`
of inline tags (<Link name="html-inlines" displayName="reference" />). And
block tags are structured in a tree. This helps to achieve _browser_-like behaviour,
when open inline tag is applied to all content (even if inside blocks) until closing tag.
All tags that are not _inline_ are considered to be _block_ ones.
## Renderer
Unlike `MarkwonHtmlParser` `Markwon` comes with a `MarkwonHtmlRenderer` by default.
Default implementation can be obtain like this:
```java
MarkwonHtmlRenderer.create();
```
Default instance have these tags _handled_:
* emphasis
* `i`
* `em`
* `cite`
* `dfn`
* strong emphasis
* `b`
* `strong`
* `sup` (super script)
* `sub` (sub script)
* underline
* `u`
* `ins`
* strike through
* `del`
* `s`
* `strike`
* `a` (link)
* `ul` (unordered list)
* `ol` (ordered list)
* `img` (image)
* `blockquote` (block quote)
* `h{1-6}` (heading)
If you wish to _extend_ default handling (or override existing),
`#builderWithDefaults` factory method can be used:
```java
MarkwonHtmlRenderer.builderWithDefaults();
```
For a completely _clean_ configurable instance `#builder` method can be used:
```java
MarkwonHtmlRenderer.builder();
```
### Custom tag handler
To configure `MarkwonHtmlRenderer` to handle tags differently or
create a new tag handler - `TagHandler` can be used
```java
public abstract class TagHandler {
public abstract void handle(
@NonNull SpannableConfiguration configuration,
@NonNull SpannableBuilder builder,
@NonNull HtmlTag tag
);
}
```
For the most simple _inline_ tag handler a `SimpleTagHandler` can be used:
```java
public abstract class SimpleTagHandler extends TagHandler {
@Nullable
public abstract Object getSpans(@NonNull SpannableConfiguration configuration, @NonNull HtmlTag tag);
}
```
For example, `EmphasisHandler`:
```java
public class EmphasisHandler extends SimpleTagHandler {
@Nullable
@Override
public Object getSpans(@NonNull SpannableConfiguration configuration, @NonNull HtmlTag tag) {
return configuration.factory().emphasis();
}
}
```
If you wish to handle a _block_ HTML node (for example `<ul><li>First<li>Second</ul>`) refer
to `ListHandler` source code for reference.
:::warning
The most important thing when implementing custom `TagHandler` is to know
what type of `HtmlTag` we are dealing with. There are 2: inline &amp; block.
Inline tag cannot contain children. Block _can_ contain children. And they
_most likely_ should also be visited and _handled_ by registered `TagHandler` (if any)
accordingly. See `TagHandler#visitChildren(configuration, builder, child);`
:::
#### Css inline style parser
When implementing own `TagHandler` you might want to inspect inline CSS styles
of a HTML element. `Markwon` provides an utility parser for that purpose:
```java
final CssInlineStyleParser inlineStyleParser = CssInlineStyleParser.create();
for (CssProperty property: inlineStyleParser.parse("width: 100%; height: 100%;")) {
// [0] = CssProperty({width=100%}),
// [1] = CssProperty({height=100%})
}
```
## Exclude HTML parsing
If you wish to exclude HTML parsing altogether, you can manually
exclude `markwon-html-parser-impl` artifact from your projects compile classpath.
This can be beneficial if you know that markdown input won't contain
HTML and/or you wish to ignore it. Excluding HTML parsing
can speed up `Markwon` parsing and will decrease final size of
`Markwon` dependency by around `100kb`.
<MavenBadge :artifact="'markwon'" />
```groovy
dependencies {
implementation("ru.noties:markwon:${markwonVersion}") {
exclude module: 'markwon-html-parser-impl'
}
}
```
Excluding `markwon-html-parser-impl` this way will result in
`MarkwonHtmlParser#noOp` implementation. No further steps are
required.
:::warning Note
Excluding `markwon-html-parser-impl` won't remove *all* the content between
HTML tags. It will if `commonmark` decides that a specific fragment is a
`HtmlBlock`, but it won't if fragment is considered a `HtmlInline` as `HtmlInline`
does not contain content (just a tag definition).
:::

243
docs/docs/image-loader.md Normal file
View File

@ -0,0 +1,243 @@
# Images
By default `Markwon` doesn't handle images. Although `AsyncDrawable.Loader` is
defined in main artifact, it does not provide implementation.
The interface is pretty simple:
```java
public interface Loader {
void load(@NonNull String destination, @NonNull AsyncDrawable drawable);
void cancel(@NonNull String destination);
}
```
## AsyncDrawableLoader
<MavenBadge artifact="markwon-image-loader" />
`AsyncDrawableLoader` from `markwon-image-loader` artifact can be used.
:::tip Install
[Learn how to add](/docs/install.md#image-loader) `markwon-image-loader` to your project
:::
Default instance of `AsyncDrawableLoader` can be obtain like this:
```java
AsyncDrawableLoader.create();
```
### Scheme support
By default `AsyncDrawableLoader` handles these URL schemes:
* `file` (including reference to `android_assets`)
* `data` <Badge text="2.0.0" /> ([wiki](https://en.wikipedia.org/wiki/Data_URI_scheme))
for inline image references
* all other schemes are considered to be network related and will be tried to obtain
from network
#### Data <Badge text="2.0.0" />
`data` scheme handler supports both `base64` encoded content and `plain`:
```html
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==" alt="Red dot" />
```
```html
<img src='data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" x="0px" y="0px" viewBox="0 0 100 100" width="15" height="15" class="icon outbound"><path fill="currentColor" d="M18.8,85.1h56l0,0c2.2,0,4-1.8,4-4v-32h-8v28h-48v-48h28v-8h-32l0,0c-2.2,0-4,1.8-4,4v56C14.8,83.3,16.6,85.1,18.8,85.1z"></path> <polygon fill="currentColor" points="45.7,48.7 51.3,54.3 77.2,28.5 77.2,37.2 85.2,37.2 85.2,14.9 62.8,14.9 62.8,22.9 71.5,22.9"></polygon></svg>' >
```
:::warning Note
Data uri works with native markdown images, but only in base64 mode:
```markdown
![svg](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGFyaWEtaGlkZGVuPSJ0cnVlIiB4PSIwcHgiIHk9IjBweCIgdmlld0JveD0iMCAwIDEwMCAxMDAiIHdpZHRoPSIxNSIgaGVpZ2h0PSIxNSIgY2xhc3M9Imljb24gb3V0Ym91bmQiPjxwYXRoIGZpbGw9ImN1cnJlbnRDb2xvciIgZD0iTTE4LjgsODUuMWg1NmwwLDBjMi4yLDAsNC0xLjgsNC00di0zMmgtOHYyOGgtNDh2LTQ4aDI4di04aC0zMmwwLDBjLTIuMiwwLTQsMS44LTQsNHY1NkMxNC44LDgzLjMsMTYuNiw4NS4xLDE4LjgsODUuMXoiPjwvcGF0aD4gPHBvbHlnb24gZmlsbD0iY3VycmVudENvbG9yIiBwb2ludHM9IjQ1LjcsNDguNyA1MS4zLDU0LjMgNzcuMiwyOC41IDc3LjIsMzcuMiA4NS4yLDM3LjIgODUuMiwxNC45IDYyLjgsMTQuOSA2Mi44LDIyLjkgNzEuNSwyMi45Ij48L3BvbHlnb24+PC9zdmc+)
```
:::
## Configuration
If you wish to configure `AsyncDrawableLoader` `#builder` factory method can be used:
```java
AsyncDrawableLoader.builder()
.build();
```
### OkHttp client
```java
AsyncDrawableLoader.builder()
.client(OkHttpClient)
.build();
```
If not provided explicitly, default `new OkHttpClient()` will be used
:::warning
This configuration option is scheduled to be removed in `3.0.0` version,
use `NetworkSchemeHandler.create(OkHttpClient)` directly by calling
`build.addSchemeHandler()`
:::
### Resources
`android.content.res.Resources` to be used when obtaining an image
from Android assets folder **and** to create Bitmaps.
```java
AsyncDrawableLoader.builder()
.resources(Resources)
.build();
```
If not provided explicitly, default `Resources.getSystem()` will be used.
:::warning
`Resources.getSystem()` can have unexpected side-effects (plus loading from
assets won't work). As a rule of thumb
always provide `AsyncDrawableLoader` with your Application's `Resources`.
To quote Android documentation for `#getSystem` method:
> Return a global shared Resources object that provides access to only
system resources (no application resources), and is not configured
for the current screen (can not use dimension units, does not
change based on orientation, etc).
:::
:::warning
This configuration option is scheduled to be removed in `3.0.0`. Construct
your `MediaDecoder`s and `SchemeHandler`s appropriately and add them via
`build.addMediaDecoder()` and `builder.addSchemeHandler`
:::
### Executor service
`ExecutorService` to be used to download images in background thread
```java
AsyncDrawableLoader.builder()
.executorService(ExecutorService)
.build();
```
If not provided explicitly, default `Executors.newCachedThreadPool()` will be used
### Error drawable
`errorDrawable` to be used when image loader encountered an error loading image
```java
AsyncDrawableLoader.builder()
.errorDrawable(Drawable)
.build();
```
if not provided explicitly, default `null` value will be used.
### Media decoder <Badge text="1.1.0" />
`MediaDecoder` is a simple asbtraction that encapsulates handling
of a specific image type.
```java
AsyncDrawableLoader.builder()
.addMediaDecoder(MediaDecoder)
.addMediaDecoders(MediaDecoder...)
.addMediaDecoders(Iterable<MediaDecoder>)
.build();
```
If not provided explicitly, default `MediaDecoder`s will be used (SVG, GIF, plain) with
provided `Resources` and `gif-autoplay=true`
`markwon-image-loader` comes with 3 `MediaDecoder` implementations:
* `SvgMediaDecoder` (based on [androidsvg](https://github.com/BigBadaboom/androidsvg))
* `GifMediaDecoder` (based on [android-gif-drawable](https://github.com/koral--/android-gif-drawable))
* `ImageMediaDecoder` (handling all _plain_ images)
:::tip
Always add a _generic_ `MediaDecoder` instance at the end of the list.
Order does matter. For example:
```java{5}
AsyncDrawableLoader.builder()
.mediaDecoders(
SvgMediaDecoder.create(Resources),
GifMediaDecoder.create(boolean),
ImageMediaDecoder.create(Resources)
)
.build();
```
:::
#### SvgMediaDecoder
```java
SvgMediaDecoder.create(Resources)
```
#### GifMediaDecoder
```java
GifMediaDecoder.create(boolean)
```
`boolean` argument stands for `autoPlayGif`
#### ImageMediaDecoder
```java
ImageMediaDecoder.create(Resources)
```
### Scheme handler <Badge text="2.0.0" />
Starting with `2.0.0` `image-loader` module introduced
`SchemeHandler` abstraction
```java
AsyncDrawableLoader.builder()
.addSchemeHandler(SchemeHandler)
.build()
```
Currently there are 3 `SchemeHandler`s that are bundled with this module:
* `NetworkSchemeHandler` (`http` and `https`)
* `FileSchemeHandler` (`file`)
* `DataUriSchemeHandler` (`data`)
#### NetworkSchemeHandler <Badge text="2.0.0" />
```java
NetworkSchemeHandler.create(OkHttpClient);
```
#### FileSchemeHandler <Badge text="2.0.0" />
Simple file handler
```java
FileSchemeHandler.create();
```
File handler that additionally allows access to Android `assets` folder
```java
FileSchemeHandler.createWithAssets(AssetManager);
```
#### DataUriSchemeHandler <Badge text="2.0.0" />
```java
DataUriSchemeHandler.create();
```
---
::: warning
Note that currently if no `SchemeHandler`s were provided via `builder.addSchemeHandler()`
call then all 3 default scheme handlers will be added. The same goes for `MediaDecoder`s
(`builder.addMediaDecoder`). This behavior is scheduled to be removed in `3.0.0`
:::

109
docs/docs/install.md Normal file
View File

@ -0,0 +1,109 @@
---
prev: false
next: /docs/getting-started.md
---
# Installation
<MavenBadges />
In order to start using `Markwon` add this to your dependencies block
in your projects `build.gradle`:
```groovy
implementation "ru.noties:markwon:${markwonVersion}"
```
This is core artifact that is sufficient to start displaying markdown in your Android applications.
`Markwon` comes with more artifacts that cover additional functionality, but they are
**not** required to be used, as most of them provide implementations for functionality
that is _interfaced_ in the core artifact
```groovy
implementation "ru.noties:markwon-image-loader:${markwonVersion}"
implementation "ru.noties:markwon-syntax-highlight:${markwonVersion}"
implementation "ru.noties:markwon-view:${markwonVersion}"
```
These artifacts share the same _version_ as the core artifact
### Image loader
```groovy
implementation "ru.noties:markwon-image-loader:${markwonVersion}"
```
Provides implementation of `AsyncDrawable.Loader` and comes with support for:
* SVG
* GIF
* Other image formats
Please refer to documentation for [image loader](/docs/image-loader.md) module
### Syntax highlight
```groovy
implementation "ru.noties:markwon-syntax-highlight:${markwonVersion}"
```
Provides implementation of `SyntaxHighlight` and allows various syntax highlighting
in your markdown based Android applications. Comes with 2 ready-to-be-used themes: `light` and `dark`.
Please refer to documentation for [syntax highlight](/docs/syntax-highlight.md) module
### View
```groovy
implementation "ru.noties:markwon-view:${markwonVersion}"
```
Provides 2 widgets to display markdown: `MarkwonView` and `MarkwonViewCompat` (subclasses
of `TextView` and `AppCompatTextView` respectively).
Please refer to documentation for [view](/docs/view.md) module
## Proguard
When using `markwon-image-loader` artifact and Proguard is enabled, add these rules
to your proguard configuration:
```proguard
-dontwarn okhttp3.**
-dontwarn okio.**
-keep class com.caverock.androidsvg.** { *; }
-dontwarn com.caverock.androidsvg.**
```
They come from dependencies that `markwon-image-loader` is using.
:::tip Other artifacts
Other artifacts do not require special Proguard rules
:::
## Snapshot
![markwon-snapshot](https://img.shields.io/nexus/s/https/oss.sonatype.org/ru.noties/markwon.svg?label=markwon)
In order to use latest `SNAPSHOT` version add snapshot repository
to your root project's `build.gradle` file:
```groovy
allprojects {
repositories {
jcenter()
google()
maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' }
}
}
```
and then in your module `build.gradle`:
```groovy
implementation "ru.noties:markwon:${markwonSnapshotVersion}"
```
Please note that `markwon-image-loader`, `markwon-syntax-highlight`
and `markwon-view` are also present in `SNAPSHOT` repository and
share the same version as main `markwon` artifact.

View File

@ -0,0 +1,69 @@
# Syntax highlight
<MavenBadge artifact="markwon-syntax-highlight" />
This is a simple module to add **syntax highlight** functionality to your markdown rendered with `Markwon` library. It is based on [Prism4j](https://github.com/noties/Prism4j) so lead there to understand how to configure `Prism4j` instance.
<img :src="$withBase('/art/markwon-syntax-default.png')" alt="theme-default" width="80%">
<img :src="$withBase('/art/markwon-syntax-darkula.png')" alt="theme-darkula" width="80%">
---
First, we need to obtain an instance of `Prism4jSyntaxHighlight` which implements Markwon's `SyntaxHighlight`:
```java
final SyntaxHighlight highlight =
Prism4jSyntaxHighlight.create(Prism4j, Prism4jTheme);
```
we also can obtain an instance of `Prism4jSyntaxHighlight` that has a _fallback_ option (if a language is not defined in `Prism4j` instance, fallback language can be used):
```java
final SyntaxHighlight highlight =
Prism4jSyntaxHighlight.create(Prism4j, Prism4jTheme, String);
```
Generally obtaining a `Prism4j` instance is pretty easy:
```java
final Prism4j prism4j = new Prism4j(new GrammarLocatorDef());
```
Where `GrammarLocatorDef` is a generated grammar locator (if you use `prism4j-bundler` annotation processor)
`Prism4jTheme` is a specific type that is defined in this module (`prism4j` doesn't know anything about rendering). It has 2 implementations:
* `Prism4jThemeDefault`
* `Prism4jThemeDarkula`
Both of them can be obtained via factory method `create`:
* `Prism4jThemeDefault.create()`
* `Prism4jThemeDarkula.create()`
But of cause nothing is stopping you from defining your own theme:
```java
public interface Prism4jTheme {
@ColorInt
int background();
@ColorInt
int textColor();
void apply(
@NonNull String language,
@NonNull Prism4j.Syntax syntax,
@NonNull SpannableStringBuilder builder,
int start,
int end
);
}
```
:::tip
You can extend `Prism4jThemeBase` which has some helper methods
:::

212
docs/docs/theme.md Normal file
View File

@ -0,0 +1,212 @@
# Theme
Here is the list of properties that can be configured via `SpannableTheme#builder` factory
method. If you wish to control what is out of this list, you can use [SpannableFactory](/docs/factory.md)
abstraction which lets you to gather full control of Spans that are used to display markdown.
* factory methods
## Link color
Controls the color of a [link](#)
<ThemeProperty name="linkColor" type="@ColorInt int" defaults="Default link color of a context where markdown is displayed <sup>*</sup>" />
<sup>*</sup> `TextPaint#linkColor` will be used to determine linkColor of a context
## Block margin
Starting margin before text content for the:
* lists
* blockquotes
* task lists
<ThemeProperty name="blockMargin" type="@Px int" defaults="24dp" />
## Block quote
Customizations for the `blockquote` stripe
> Quote
### Stripe width
Width of a blockquote stripe
<ThemeProperty name="blockQuoteWidth" type="@Px int" defaults="1/4 of the <a href='#block-margin'>block margin</a>" />
### Stripe color
Color of a blockquote stripe
<ThemeProperty name="blockQuoteColor" type="@ColorInt int" defaults="textColor with <code>25</code> (0-255) alpha value" />
## List
### List item color
Controls the color of a list item. For ordered list: leading number,
for unordered list: bullet.
* UL
1. OL
<ThemeProperty name="listItemColor" type="@ColorInt int" defaults="Text color" />
### Bullet item stroke width
Border width of a bullet list item (level 2)
* First
* * Second
* * * Third
<ThemeProperty name="bulletListItemStrokeWidth" type="@Px int" defaults="Stroke width of TextPaint" />
### Bullet width
The width of the bullet item
* First
* Second
* Third
<ThemeProperty name="bulletWidth" type="@Px int" defaults="min(<a href='#block-margin'>blockMargin</a>, lineHeight) / 2" />
## Code
### Inline code text color
The color of the `code` content
<ThemeProperty name="codeTextColor" type="@ColorInt int" defaults="Content text color" />
### Inline code background color
The color of `background` of a code content
<ThemeProperty name="codeBackgroundColor" type="@ColorInt int" defaults="<a href='#inline-code-text-color'>inline code text color</a> with 25 (0-255) alpha" />
### Block code text color
```
The color of code block text
```
<ThemeProperty name="codeBlockTextColor" type="@ColorInt int" defaults="<a href='#inline-code-text-color'>inline code text color</a>" />
### Block code background color
```
The color of background of code block text
```
<ThemeProperty name="codeBlockBackgroundColor" type="@ColorInt int" defaults="<a href='#inline-code-background-color'>inline code background color</a>" />
### Block code leading margin
Leading margin for the block code content
<ThemeProperty name="codeMultilineMargin" type="@Px int" defaults="Width of the space character" />
### Code typeface
Typeface of code content
<ThemeProperty name="codeTypeface" type="android.graphics.Typeface" defaults="Typeface.MONOSPACE" />
### Code text size
Text size of code content
<ThemeProperty name="codeTextSize" type="@Px int" defaults="(Content text size) * 0.87 if no custom <a href='#code-typeface'>Typeface</a> was set, otherwise (content text size)" />
## Heading
### Break height
The height of a brake under H1 &amp; H2
<ThemeProperty name="headingBreakHeight" type="@Px int" defaults="Stroke width of context TextPaint" />
### Break color
The color of a brake under H1 &amp; H2
<ThemeProperty name="headingBreakColor" type="@ColorInt int" defaults="(text color) with 75 (0-255) alpha" />
### Typeface <Badge text="1.1.0" />
The typeface of heading elements
<ThemeProperty name="headingTypeface" type="android.graphics.Typeface" defaults="default text Typeface" />
### Text size <Badge text="1.1.0" />
Array of heading text sizes _ratio_ that is applied to text size
<ThemeProperty name="headingTextSizeMultipliers" type="float[]" defaults="<code>{2.F, 1.5F, 1.17F, 1.F, .83F, .67F}</code> (HTML spec)" />
## Script ratio
Ratio to be applied for `sup` (super script) &amp; `sub` (sub script)
<ThemeProperty name="scriptTextSizeRatio" type="float" defaults="0.75F" />
## Thematic break
### Color
Color of a thematic break
<ThemeProperty name="thematicBreakColor" type="@ColorInt int" defaults="(text color) with 25 (0-255) alpha" />
### Height
Height of a thematic break
<ThemeProperty name="thematicBreakHeight" type="@Px int" defaults="Stroke width of context TextPaint" />
## Table
### Cell padding
Padding inside a table cell
<ThemeProperty name="tableCellPadding" type="@Px int" defaults="0" />
### Border color
The color of table borders
<ThemeProperty name="tableBorderColor" type="@ColorInt int" defaults="(text color) with 75 (0-255) alpha" />
### Border width
The width of table borders
<ThemeProperty name="tableBorderWidth" type="@Px int" defaults="Stroke with of context TextPaint" />
### Odd row background
Background of an odd table row
<ThemeProperty name="tableOddRowBackgroundColor" type="@ColorInt int" defaults="(text color) with 22 (0-255) alpha" />
### Even row background <Badge text="1.1.1" />
Background of an even table row
<ThemeProperty name="tableEventRowBackgroundColor" type="@ColorInt int" defaults="0" />
### Header row background <Badge text="1.1.1" />
Background of header table row
<ThemeProperty name="tableHeaderRowBackgroundColor" type="@ColorInt int" defaults="0" />
## Task list drawable <Badge text="1.0.1" />
Drawable of task list item
<ThemeProperty name="taskListDrawable" type="android.graphics.drawable.Drawable" defaults="ru.noties.markwon.spans.TaskListDrawable" />

41
docs/docs/view.md Normal file
View File

@ -0,0 +1,41 @@
# MarkwonView
<MavenBadge artifact="markwon-view" />
This is simple library containing 2 views that are able to display markdown:
* MarkwonView - extends `android.view.TextView`
* MarkwonViewCompat - extends `android.support.v7.widget.AppCompatTextView`
Both of them implement common `IMarkwonView` interface:
```java
public interface IMarkwonView {
interface ConfigurationProvider {
@NonNull
SpannableConfiguration provide(@NonNull Context context);
}
void setConfigurationProvider(@NonNull ConfigurationProvider provider);
void setMarkdown(@Nullable String markdown);
void setMarkdown(@Nullable SpannableConfiguration configuration, @Nullable String markdown);
@Nullable
String getMarkdown();
}
```
Both views support layout-preview in Android Studio (with some exceptions, for example, bold span is not rendered due to some limitations of layout preview).
These are XML attributes:
```
app:mv_markdown="string"
app:mv_configurationProvider="string"
```
`mv_markdown` accepts a string and represents raw markdown
`mv_configurationProvider` accepts a string and represents a full class name of a class of type `ConfigurationProvider`,
for example: `com.example.my.package.MyConfigurationProvider` (this class must have an empty constructor
in order to be instantiated via reflection).
Please note that those views parse markdown in main thread, so their usage must be for relatively small markdown portions only

10685
docs/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

9
docs/package.json Normal file
View File

@ -0,0 +1,9 @@
{
"scripts": {
"docs:build": "vuepress build"
},
"dependencies": {
"markdown-it-task-lists": "^2.1.1",
"vuepress": "^0.14.2"
}
}

View File

@ -6,7 +6,7 @@ org.gradle.configureondemand=true
android.enableBuildCache=true
android.buildCacheDir=build/pre-dex-cache
VERSION_NAME=1.1.1
VERSION_NAME=2.0.0
GROUP=ru.noties
POM_DESCRIPTION=Markwon

View File

@ -1,34 +0,0 @@
apply plugin: 'com.android.library'
android {
compileSdkVersion TARGET_SDK
buildToolsVersion BUILD_TOOLS
defaultConfig {
minSdkVersion MIN_SDK
targetSdkVersion TARGET_SDK
versionCode 1
versionName version
}
lintOptions {
// okio....
disable 'InvalidPackage'
}
}
dependencies {
api project(':library')
api ANDROID_SVG
api ANDROID_GIF
api OK_HTTP
}
afterEvaluate {
generateReleaseBuildConfig.enabled = false
}
if (hasProperty('release')) {
apply from: 'https://raw.githubusercontent.com/noties/gradle-mvn-push/master/gradle-mvn-push-aar.gradle'
}

View File

@ -1,28 +0,0 @@
apply plugin: 'com.android.library'
android {
compileSdkVersion TARGET_SDK
buildToolsVersion BUILD_TOOLS
defaultConfig {
minSdkVersion MIN_SDK
targetSdkVersion TARGET_SDK
versionCode 1
versionName version
}
}
dependencies {
api SUPPORT_ANNOTATIONS
api PRISM_4J
api project(':library')
}
afterEvaluate {
generateReleaseBuildConfig.enabled = false
}
if (hasProperty('release')) {
apply from: 'https://raw.githubusercontent.com/noties/gradle-mvn-push/master/gradle-mvn-push-aar.gradle'
}

View File

@ -1,3 +0,0 @@
POM_NAME=Markwon
POM_ARTIFACT_ID=markwon-syntax
POM_PACKAGING=aar

View File

@ -1,27 +0,0 @@
apply plugin: 'com.android.library'
android {
compileSdkVersion TARGET_SDK
buildToolsVersion BUILD_TOOLS
defaultConfig {
minSdkVersion MIN_SDK
targetSdkVersion TARGET_SDK
versionCode 1
versionName version
}
}
dependencies {
api project(':library')
compileOnly SUPPORT_APP_COMPAT
}
afterEvaluate {
generateReleaseBuildConfig.enabled = false
}
if (hasProperty('release')) {
apply from: 'https://raw.githubusercontent.com/noties/gradle-mvn-push/master/gradle-mvn-push-aar.gradle'
}

View File

@ -1,29 +0,0 @@
apply plugin: 'com.android.library'
android {
compileSdkVersion TARGET_SDK
buildToolsVersion BUILD_TOOLS
defaultConfig {
minSdkVersion MIN_SDK
targetSdkVersion TARGET_SDK
versionCode 1
versionName version
}
}
dependencies {
api SUPPORT_ANNOTATIONS
api COMMON_MARK
api COMMON_MARK_STRIKETHROUGHT
api COMMON_MARK_TABLE
}
afterEvaluate {
generateReleaseBuildConfig.enabled = false
}
if (hasProperty('release')) {
apply from: 'https://raw.githubusercontent.com/noties/gradle-mvn-push/master/gradle-mvn-push-aar.gradle'
}

View File

@ -1,22 +0,0 @@
package ru.noties.markwon.renderer.html;
import android.support.annotation.NonNull;
import ru.noties.markwon.SpannableFactory;
class BoldProvider implements SpannableHtmlParser.SpanProvider {
private final SpannableFactory factory;
/**
* @since 1.1.0
*/
BoldProvider(@NonNull SpannableFactory factory) {
this.factory = factory;
}
@Override
public Object provide(@NonNull SpannableHtmlParser.Tag tag) {
return factory.strongEmphasis();
}
}

View File

@ -1,224 +0,0 @@
package ru.noties.markwon.renderer.html;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.TextUtils;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import ru.noties.markwon.SpannableFactory;
import ru.noties.markwon.UrlProcessor;
import ru.noties.markwon.renderer.ImageSize;
import ru.noties.markwon.renderer.ImageSizeResolver;
import ru.noties.markwon.spans.AsyncDrawable;
import ru.noties.markwon.spans.SpannableTheme;
class ImageProviderImpl implements SpannableHtmlParser.ImageProvider {
private final SpannableFactory factory;
private final SpannableTheme theme;
private final AsyncDrawable.Loader loader;
private final UrlProcessor urlProcessor;
private final ImageSizeResolver imageSizeResolver;
ImageProviderImpl(
@NonNull SpannableFactory factory,
@NonNull SpannableTheme theme,
@NonNull AsyncDrawable.Loader loader,
@NonNull UrlProcessor urlProcessor,
@NonNull ImageSizeResolver imageSizeResolver
) {
this.factory = factory;
this.theme = theme;
this.loader = loader;
this.urlProcessor = urlProcessor;
this.imageSizeResolver = imageSizeResolver;
}
@Override
public Spanned provide(@NonNull SpannableHtmlParser.Tag tag) {
final Spanned spanned;
final Map<String, String> attributes = tag.attributes();
final String src = attributes.get("src");
final String alt = attributes.get("alt");
if (!TextUtils.isEmpty(src)) {
final String destination = urlProcessor.process(src);
final String replacement;
if (!TextUtils.isEmpty(alt)) {
replacement = alt;
} else {
replacement = "\uFFFC";
}
final Object span = factory.image(
theme,
destination,
loader,
imageSizeResolver,
parseImageSize(attributes),
false);
final SpannableString string = new SpannableString(replacement);
if (span != null) {
final int length = string.length();
if (span.getClass().isArray()) {
for (Object o : ((Object[]) span)) {
string.setSpan(o, 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
} else {
string.setSpan(span, 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
spanned = string;
} else {
spanned = null;
}
return spanned;
}
@Nullable
private static ImageSize parseImageSize(@NonNull Map<String, String> attributes) {
final ImageSize imageSize;
final StyleProvider styleProvider = new StyleProvider(attributes.get("style"));
final ImageSize.Dimension width = parseDimension(extractDimension("width", attributes, styleProvider));
final ImageSize.Dimension height = parseDimension(extractDimension("height", attributes, styleProvider));
if (width == null
&& height == null) {
imageSize = null;
} else {
imageSize = new ImageSize(width, height);
}
return imageSize;
}
@Nullable
private static String extractDimension(@NonNull String name, @NonNull Map<String, String> attributes, @NonNull StyleProvider styleProvider) {
final String out;
final String inline = attributes.get(name);
if (!TextUtils.isEmpty(inline)) {
out = inline;
} else {
out = extractDimensionFromStyle(name, styleProvider);
}
return out;
}
@Nullable
private static String extractDimensionFromStyle(@NonNull String name, @NonNull StyleProvider styleProvider) {
return styleProvider.attributes().get(name);
}
@Nullable
private static ImageSize.Dimension parseDimension(@Nullable String raw) {
// a set of digits, then dimension unit (allow floating)
final ImageSize.Dimension dimension;
final int length = raw != null
? raw.length()
: 0;
if (length == 0) {
dimension = null;
} else {
// first digit to find -> unit is finished (can be null)
int index = -1;
for (int i = length - 1; i >= 0; i--) {
if (Character.isDigit(raw.charAt(i))) {
index = i;
break;
}
}
// no digits -> no dimension
if (index == -1) {
dimension = null;
} else {
final String value;
final String unit;
// no unit is specified
if (index == length - 1) {
value = raw;
unit = null;
} else {
value = raw.substring(0, index + 1);
unit = raw.substring(index + 1);
}
ImageSize.Dimension inner;
try {
final float floatValue = Float.parseFloat(value);
inner = new ImageSize.Dimension(floatValue, unit);
} catch (NumberFormatException e) {
inner = null;
}
dimension = inner;
}
}
return dimension;
}
private static class StyleProvider {
private final String style;
private Map<String, String> attributes;
StyleProvider(@Nullable String style) {
this.style = style;
}
@NonNull
Map<String, String> attributes() {
final Map<String, String> out;
if (attributes != null) {
out = attributes;
} else {
if (TextUtils.isEmpty(style)) {
out = attributes = Collections.emptyMap();
} else {
final String[] split = style.split(";");
final Map<String, String> map = new HashMap<>(split.length);
String[] parts;
for (String s : split) {
if (!TextUtils.isEmpty(s)) {
parts = s.split(":");
if (parts.length == 2) {
map.put(parts[0].trim(), parts[1].trim());
}
}
}
out = attributes = map;
}
}
return out;
}
}
}

View File

@ -1,22 +0,0 @@
package ru.noties.markwon.renderer.html;
import android.support.annotation.NonNull;
import ru.noties.markwon.SpannableFactory;
class ItalicsProvider implements SpannableHtmlParser.SpanProvider {
private final SpannableFactory factory;
/**
* @since 1.1.0
*/
ItalicsProvider(@NonNull SpannableFactory factory) {
this.factory = factory;
}
@Override
public Object provide(@NonNull SpannableHtmlParser.Tag tag) {
return factory.emphasis();
}
}

View File

@ -1,49 +0,0 @@
package ru.noties.markwon.renderer.html;
import android.support.annotation.NonNull;
import android.text.TextUtils;
import java.util.Map;
import ru.noties.markwon.SpannableFactory;
import ru.noties.markwon.UrlProcessor;
import ru.noties.markwon.spans.LinkSpan;
import ru.noties.markwon.spans.SpannableTheme;
class LinkProvider implements SpannableHtmlParser.SpanProvider {
private final SpannableFactory factory;
private final SpannableTheme theme;
private final UrlProcessor urlProcessor;
private final LinkSpan.Resolver resolver;
LinkProvider(
@NonNull SpannableFactory factory,
@NonNull SpannableTheme theme,
@NonNull UrlProcessor urlProcessor,
@NonNull LinkSpan.Resolver resolver) {
this.factory = factory;
this.theme = theme;
this.urlProcessor = urlProcessor;
this.resolver = resolver;
}
@Override
public Object provide(@NonNull SpannableHtmlParser.Tag tag) {
final Object span;
final Map<String, String> attributes = tag.attributes();
final String href = attributes.get("href");
if (!TextUtils.isEmpty(href)) {
final String destination = urlProcessor.process(href);
span = factory.link(theme, destination, resolver);
} else {
span = null;
}
return span;
}
}

View File

@ -1,332 +0,0 @@
package ru.noties.markwon.renderer.html;
import android.annotation.TargetApi;
import android.os.Build;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.text.Html;
import android.text.Spanned;
import java.util.HashMap;
import java.util.Map;
import ru.noties.markwon.LinkResolverDef;
import ru.noties.markwon.SpannableFactory;
import ru.noties.markwon.UrlProcessor;
import ru.noties.markwon.UrlProcessorNoOp;
import ru.noties.markwon.renderer.ImageSizeResolver;
import ru.noties.markwon.renderer.ImageSizeResolverDef;
import ru.noties.markwon.spans.AsyncDrawable;
import ru.noties.markwon.spans.LinkSpan;
import ru.noties.markwon.spans.SpannableTheme;
@SuppressWarnings("WeakerAccess")
public class SpannableHtmlParser {
/**
* @since 1.1.0
*/
@NonNull
public static SpannableHtmlParser create(
@NonNull SpannableFactory factory,
@NonNull SpannableTheme theme,
@NonNull AsyncDrawable.Loader loader,
@NonNull UrlProcessor urlProcessor,
@NonNull LinkSpan.Resolver resolver,
@NonNull ImageSizeResolver imageSizeResolver
) {
return builderWithDefaults(factory, theme, loader, urlProcessor, resolver, imageSizeResolver).build();
}
@NonNull
public static Builder builder() {
return new Builder();
}
/**
* @since 1.1.0
*/
@NonNull
public static Builder builderWithDefaults(@NonNull SpannableFactory factory, @NonNull SpannableTheme theme) {
return builderWithDefaults(
factory,
theme,
null,
null,
null,
null);
}
/**
* Updated in 1.0.1: added imageSizeResolverArgument
* Updated in 1.1.0: add SpannableFactory
*/
@NonNull
public static Builder builderWithDefaults(
@NonNull SpannableFactory factory,
@NonNull SpannableTheme theme,
@Nullable AsyncDrawable.Loader asyncDrawableLoader,
@Nullable UrlProcessor urlProcessor,
@Nullable LinkSpan.Resolver resolver,
@Nullable ImageSizeResolver imageSizeResolver
) {
if (urlProcessor == null) {
urlProcessor = new UrlProcessorNoOp();
}
if (resolver == null) {
resolver = new LinkResolverDef();
}
final BoldProvider boldProvider = new BoldProvider(factory);
final ItalicsProvider italicsProvider = new ItalicsProvider(factory);
final StrikeProvider strikeProvider = new StrikeProvider(factory);
final ImageProvider imageProvider;
if (asyncDrawableLoader != null) {
if (imageSizeResolver == null) {
imageSizeResolver = new ImageSizeResolverDef();
}
imageProvider = new ImageProviderImpl(
factory,
theme,
asyncDrawableLoader,
urlProcessor,
imageSizeResolver);
} else {
imageProvider = null;
}
return new Builder()
.simpleTag("b", boldProvider)
.simpleTag("strong", boldProvider)
.simpleTag("i", italicsProvider)
.simpleTag("em", italicsProvider)
.simpleTag("cite", italicsProvider)
.simpleTag("dfn", italicsProvider)
.simpleTag("sup", new SuperScriptProvider(factory, theme))
.simpleTag("sub", new SubScriptProvider(factory, theme))
.simpleTag("u", new UnderlineProvider(factory))
.simpleTag("del", strikeProvider)
.simpleTag("s", strikeProvider)
.simpleTag("strike", strikeProvider)
.simpleTag("a", new LinkProvider(factory, theme, urlProcessor, resolver))
.imageProvider(imageProvider);
}
// for simple tags without arguments
// <b>, <i>, etc
public interface SpanProvider {
Object provide(@NonNull Tag tag);
}
public interface ImageProvider {
Spanned provide(@NonNull Tag tag);
}
public interface HtmlParser {
// returns span for a simple content
Object getSpan(@NonNull String html);
Spanned parse(@NonNull String html);
}
private final Map<String, SpanProvider> simpleTags;
private final ImageProvider imageProvider;
private final HtmlParser parser;
private final TagParser tagParser;
private SpannableHtmlParser(Builder builder) {
this.simpleTags = builder.simpleTags;
this.imageProvider = builder.imageProvider;
this.parser = builder.parser;
this.tagParser = new TagParser();
}
@Nullable
public Tag parseTag(String html) {
return tagParser.parse(html);
}
@Nullable
public Object getSpanForTag(@NonNull Tag tag) {
// check if we have specific handler for tag.name
final Object out;
final SpanProvider provider = simpleTags.get(tag.name);
if (provider != null) {
out = provider.provide(tag);
} else {
// let's prepare mock content & extract spans from it
// actual content doesn't matter, here it's just `abc`
final String mock = tag.raw + "abc" + "</" + tag.name + ">";
out = parser.getSpan(mock);
}
return out;
}
// if tag is NULL, then it's HtmlBlock... else just a void tag
public Spanned getSpanned(@Nullable Tag tag, String html) {
final Spanned spanned;
if (tag != null && "img".equals(tag.name) && imageProvider != null) {
spanned = imageProvider.provide(tag);
} else {
spanned = parser.parse(html);
}
return spanned;
}
public static class Builder {
private final Map<String, SpanProvider> simpleTags = new HashMap<>(3);
private ImageProvider imageProvider;
private HtmlParser parser;
@NonNull
Builder simpleTag(@NonNull String tag, @NonNull SpanProvider provider) {
simpleTags.put(tag, provider);
return this;
}
@NonNull
public Builder imageProvider(@Nullable ImageProvider imageProvider) {
this.imageProvider = imageProvider;
return this;
}
@NonNull
public Builder parser(@NonNull HtmlParser parser) {
this.parser = parser;
return this;
}
@NonNull
public SpannableHtmlParser build() {
if (parser == null) {
parser = DefaultHtmlParser.create();
}
return new SpannableHtmlParser(this);
}
}
public static class Tag {
private final String raw;
private final String name;
private final Map<String, String> attributes;
private final boolean opening;
private final boolean voidTag;
public Tag(String raw, String name, @NonNull Map<String, String> attributes, boolean opening, boolean voidTag) {
this.raw = raw;
this.name = name;
this.attributes = attributes;
this.opening = opening;
this.voidTag = voidTag;
}
public String raw() {
return raw;
}
public String name() {
return name;
}
@NonNull
public Map<String, String> attributes() {
return attributes;
}
public boolean opening() {
return opening;
}
public boolean voidTag() {
return voidTag;
}
@Override
public String toString() {
return "Tag{" +
"raw='" + raw + '\'' +
", name='" + name + '\'' +
", attributes=" + attributes +
", opening=" + opening +
", voidTag=" + voidTag +
'}';
}
}
public static abstract class DefaultHtmlParser implements HtmlParser {
public static DefaultHtmlParser create() {
final DefaultHtmlParser parser;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
parser = new Parser24();
} else {
parser = new ParserPre24();
}
return parser;
}
Object getSpan(Spanned spanned) {
final Object out;
final Object[] spans;
final int length = spanned != null ? spanned.length() : 0;
if (length == 0) {
spans = null;
} else {
spans = spanned.getSpans(0, length, Object.class);
}
if (spans != null
&& spans.length > 0) {
out = spans[0];
} else {
out = null;
}
return out;
}
@SuppressWarnings("deprecation")
private static class ParserPre24 extends DefaultHtmlParser {
@Override
public Object getSpan(@NonNull String html) {
return getSpan(parse(html));
}
@Override
public Spanned parse(@NonNull String html) {
return Html.fromHtml(html, null, null);
}
}
@TargetApi(Build.VERSION_CODES.N)
private static class Parser24 extends DefaultHtmlParser {
@Override
public Object getSpan(@NonNull String html) {
return getSpan(parse(html));
}
@Override
public Spanned parse(@NonNull String html) {
return Html.fromHtml(html, Html.FROM_HTML_MODE_COMPACT, null, null);
}
}
}
}

View File

@ -1,22 +0,0 @@
package ru.noties.markwon.renderer.html;
import android.support.annotation.NonNull;
import ru.noties.markwon.SpannableFactory;
class StrikeProvider implements SpannableHtmlParser.SpanProvider {
private final SpannableFactory factory;
/**
* @since 1.1.0
*/
StrikeProvider(@NonNull SpannableFactory factory) {
this.factory = factory;
}
@Override
public Object provide(@NonNull SpannableHtmlParser.Tag tag) {
return factory.strikethrough();
}
}

View File

@ -1,22 +0,0 @@
package ru.noties.markwon.renderer.html;
import android.support.annotation.NonNull;
import ru.noties.markwon.SpannableFactory;
import ru.noties.markwon.spans.SpannableTheme;
class SubScriptProvider implements SpannableHtmlParser.SpanProvider {
private final SpannableFactory factory;
private final SpannableTheme theme;
SubScriptProvider(@NonNull SpannableFactory factory, @NonNull SpannableTheme theme) {
this.factory = factory;
this.theme = theme;
}
@Override
public Object provide(@NonNull SpannableHtmlParser.Tag tag) {
return factory.subScript(theme);
}
}

View File

@ -1,22 +0,0 @@
package ru.noties.markwon.renderer.html;
import android.support.annotation.NonNull;
import ru.noties.markwon.SpannableFactory;
import ru.noties.markwon.spans.SpannableTheme;
class SuperScriptProvider implements SpannableHtmlParser.SpanProvider {
private final SpannableFactory factory;
private final SpannableTheme theme;
SuperScriptProvider(@NonNull SpannableFactory factory, @NonNull SpannableTheme theme) {
this.factory = factory;
this.theme = theme;
}
@Override
public Object provide(@NonNull SpannableHtmlParser.Tag tag) {
return factory.superScript(theme);
}
}

View File

@ -1,155 +0,0 @@
package ru.noties.markwon.renderer.html;
import android.support.annotation.Nullable;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
class TagParser {
private static final Set<String> VOID_TAGS;
static {
final String[] tags = {
"area", "base", "br", "col", "embed", "hr", "img", "input",
"keygen", "link", "meta", "param", "source", "track", "wbr"
};
final Set<String> set = new HashSet<>(tags.length);
Collections.addAll(set, tags);
VOID_TAGS = Collections.unmodifiableSet(set);
}
TagParser() {
}
@Nullable
SpannableHtmlParser.Tag parse(String html) {
final SpannableHtmlParser.Tag tag;
final int length = html != null
? html.length()
: 0;
// absolutely minimum (`<i>`)
if (length < 3) {
tag = null;
} else {
// // okay, we will consider a tag a void one if it's in our void list tag
final boolean closing = '<' == html.charAt(0) && '/' == html.charAt(1);
final boolean voidTag;
Map<String, String> attributes = null;
final StringBuilder builder = new StringBuilder();
String name = null;
String pendingAttribute = null;
char c;
char valueDelimiter = '\0';
for (int i = 0; i < length; i++) {
c = html.charAt(i);
// no more handling
if ('>' == c
|| '\\' == c) {
break;
}
if (name == null) {
if (Character.isSpaceChar(c)) {
//noinspection StatementWithEmptyBody
if (builder.length() == 0) {
// ignore it, we must wait until we have tagName
} else {
name = builder.toString();
// clear buffer
builder.setLength(0);
}
} else {
if (Character.isLetterOrDigit(c)) {
builder.append(c);
} /*else {
// we allow non-letter-digit only if builder.length == 0
// if we have already started
}*/
}
} else if (pendingAttribute == null) {
// we start checking for attribute
// ignore non-letter-digits before
if (Character.isLetterOrDigit(c)) {
builder.append(c);
} else /*if ('=' == c)*/ {
// attribute name is finished (only if we have already added something)
// else it's trailing chars that we are not interested in
if (builder.length() > 0) {
pendingAttribute = builder.toString();
builder.setLength(0);
}
}
} else {
// first char that we will meet will be the delimiter
if (valueDelimiter == '\0') {
valueDelimiter = c;
} else {
if (c == valueDelimiter) {
if (attributes == null) {
attributes = new HashMap<>(3);
}
attributes.put(pendingAttribute, builder.toString());
pendingAttribute = null;
valueDelimiter = '\0';
builder.setLength(0);
} else {
builder.append(c);
}
}
}
}
if (builder.length() > 0) {
if (name == null) {
name = builder.toString();
} else if (pendingAttribute != null) {
if (attributes == null) {
attributes = new HashMap<>(3);
}
attributes.put(pendingAttribute, builder.toString());
}
}
// in case of wrong parsing
if (name == null) {
tag = null;
} else {
voidTag = !closing && VOID_TAGS.contains(name);
final Map<String, String> attributesMap;
if (attributes == null
|| attributes.size() == 0) {
//noinspection unchecked
attributesMap = Collections.EMPTY_MAP;
} else {
attributesMap = Collections.unmodifiableMap(attributes);
}
tag = new SpannableHtmlParser.Tag(html, name, attributesMap, !closing, voidTag);
}
}
return tag;
}
}

View File

@ -1,22 +0,0 @@
package ru.noties.markwon.renderer.html;
import android.support.annotation.NonNull;
import ru.noties.markwon.SpannableFactory;
class UnderlineProvider implements SpannableHtmlParser.SpanProvider {
private final SpannableFactory factory;
/**
* @since 1.1.0
*/
UnderlineProvider(@NonNull SpannableFactory factory) {
this.factory = factory;
}
@Override
public Object provide(@NonNull SpannableHtmlParser.Tag tag) {
return factory.underline();
}
}

View File

@ -0,0 +1,23 @@
apply plugin: 'com.android.library'
android {
compileSdkVersion config['compile-sdk']
buildToolsVersion config['build-tools']
defaultConfig {
minSdkVersion config['min-sdk']
targetSdkVersion config['target-sdk']
versionCode 1
versionName version
}
}
dependencies {
deps.with {
api it['support-annotations']
}
}
registerArtifact(this)

View File

@ -0,0 +1,3 @@
POM_NAME=Markwon
POM_ARTIFACT_ID=markwon-html-parser-api
POM_PACKAGING=aar

View File

@ -0,0 +1 @@
<manifest package="ru.noties.markwon.html.api" />

View File

@ -0,0 +1,94 @@
package ru.noties.markwon.html.api;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import java.util.List;
import java.util.Map;
/**
* @see Inline
* @see Block
* @since 2.0.0
*/
public interface HtmlTag {
int NO_END = -1;
/**
* @return normalized tag name (lower-case)
*/
@NonNull
String name();
/**
* @return index at which this tag starts
*/
int start();
/**
* @return index at which this tag ends
*/
int end();
/**
* @return flag indicating if this tag has no content (when start == end)
*/
boolean isEmpty();
/**
* @return flag indicating if this tag is closed (has valid start and end)
* @see #NO_END
*/
boolean isClosed();
@NonNull
Map<String, String> attributes();
/**
* @see Inline
*/
boolean isInline();
/**
* @see Block
*/
boolean isBlock();
@NonNull
Inline getAsInline();
@NonNull
Block getAsBlock();
/**
* Represents <em>really</em> inline HTML tags (unlile commonmark definitions)
*/
interface Inline extends HtmlTag {
}
/**
* Represents HTML block tags. Please note that all tags that are not inline should be
* considered as block tags
*/
interface Block extends HtmlTag {
/**
* @return parent {@link Block} or null if there is no parent (this block is at root level)
*/
@Nullable
Block parent();
/**
* @return list of children
*/
@NonNull
List<Block> children();
/**
* @return a flag indicating if this {@link Block} is at the root level (shortcut to calling:
* {@code parent() == null}
*/
boolean isRoot();
}
}

View File

@ -0,0 +1,60 @@
package ru.noties.markwon.html.api;
import android.support.annotation.NonNull;
import java.util.List;
/**
* @since 2.0.0
*/
public abstract class MarkwonHtmlParser {
/**
* Factory method to create a `no-op` implementation (no parsing)
*/
@NonNull
public static MarkwonHtmlParser noOp() {
return new MarkwonHtmlParserNoOp();
}
public interface FlushAction<T> {
void apply(@NonNull List<T> tags);
}
public abstract <T extends Appendable & CharSequence> void processFragment(
@NonNull T output,
@NonNull String htmlFragment);
/**
* After this method exists a {@link MarkwonHtmlParser} will clear internal state for stored tags.
* If you wish to process them further after this method exists create own copy of supplied
* collection.
*
* @param documentLength known document length. This value is used to close all non-closed tags.
* If you wish to keep them open (do not force close at the end of a
* document pass here {@link HtmlTag#NO_END}. Later non-closed tags
* can be detected by calling {@link HtmlTag#isClosed()}
* @param action {@link FlushAction} to be called with resulting tags ({@link ru.noties.markwon.html.api.HtmlTag.Inline})
*/
public abstract void flushInlineTags(
int documentLength,
@NonNull FlushAction<HtmlTag.Inline> action);
/**
* After this method exists a {@link MarkwonHtmlParser} will clear internal state for stored tags.
* If you wish to process them further after this method exists create own copy of supplied
* collection.
*
* @param documentLength known document length. This value is used to close all non-closed tags.
* If you wish to keep them open (do not force close at the end of a
* document pass here {@link HtmlTag#NO_END}. Later non-closed tags
* can be detected by calling {@link HtmlTag#isClosed()}
* @param action {@link FlushAction} to be called with resulting tags ({@link ru.noties.markwon.html.api.HtmlTag.Block})
*/
public abstract void flushBlockTags(
int documentLength,
@NonNull FlushAction<HtmlTag.Block> action);
public abstract void reset();
}

View File

@ -0,0 +1,32 @@
package ru.noties.markwon.html.api;
import android.support.annotation.NonNull;
import java.util.Collections;
/**
* @see MarkwonHtmlParser
* @since 2.0.0
*/
class MarkwonHtmlParserNoOp extends MarkwonHtmlParser {
@Override
public <T extends Appendable & CharSequence> void processFragment(@NonNull T output, @NonNull String htmlFragment) {
}
@Override
public void flushInlineTags(int documentLength, @NonNull FlushAction<HtmlTag.Inline> action) {
action.apply(Collections.<HtmlTag.Inline>emptyList());
}
@Override
public void flushBlockTags(int documentLength, @NonNull FlushAction<HtmlTag.Block> action) {
action.apply(Collections.<HtmlTag.Block>emptyList());
}
@Override
public void reset() {
}
}

View File

@ -0,0 +1,31 @@
apply plugin: 'com.android.library'
android {
compileSdkVersion config['compile-sdk']
buildToolsVersion config['build-tools']
defaultConfig {
minSdkVersion config['min-sdk']
targetSdkVersion config['target-sdk']
versionCode 1
versionName version
}
}
dependencies {
api project(':markwon-html-parser-api')
deps.with {
api it['support-annotations']
api it['commonmark']
}
deps.test.with {
testImplementation it['junit']
testImplementation it['robolectric']
}
}
registerArtifact(this)

View File

@ -0,0 +1,3 @@
POM_NAME=Markwon
POM_ARTIFACT_ID=markwon-html-parser-impl
POM_PACKAGING=aar

View File

@ -0,0 +1 @@
<manifest package="ru.noties.markwon.html.impl" />

View File

@ -0,0 +1,35 @@
package ru.noties.markwon.html.impl;
import android.support.annotation.NonNull;
import java.io.IOException;
abstract class AppendableUtils {
static void appendQuietly(@NonNull Appendable appendable, char c) {
try {
appendable.append(c);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
static void appendQuietly(@NonNull Appendable appendable, @NonNull CharSequence cs) {
try {
appendable.append(cs);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
static void appendQuietly(@NonNull Appendable appendable, @NonNull CharSequence cs, int start, int end) {
try {
appendable.append(cs, start, end);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private AppendableUtils() {
}
}

View File

@ -0,0 +1,55 @@
package ru.noties.markwon.html.impl;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import ru.noties.markwon.html.api.HtmlTag;
/**
* This class will be used to append some text to output in order to
* apply a Span for this tag. Please note that this class will be used for
* _void_ tags and tags that are self-closed (even if HTML spec doesn\'t specify
* a tag as self-closed). This is due to the fact that underlying parser does not
* validate context and does not check if a tag is correctly used. Plus it will be
* used for tags without content, for example: {@code <my-custom-element></my-custom-element>}
*
* @since 2.0.0
*/
public class HtmlEmptyTagReplacement {
@NonNull
public static HtmlEmptyTagReplacement create() {
return new HtmlEmptyTagReplacement();
}
private static final String IMG_REPLACEMENT = "\uFFFC";
/**
* @return replacement for supplied startTag or null if no replacement should occur (which will
* lead to `Inline` tag have start &amp; end the same value, thus not applicable for applying a Span)
*/
@Nullable
public String replace(@NonNull HtmlTag tag) {
final String replacement;
final String name = tag.name();
if ("br".equals(name)) {
replacement = "\n";
} else if ("img".equals(name)) {
final String alt = tag.attributes().get("alt");
if (alt == null
|| alt.length() == 0) {
// no alt is provided
replacement = IMG_REPLACEMENT;
} else {
replacement = alt;
}
} else {
replacement = null;
}
return replacement;
}
}

View File

@ -0,0 +1,210 @@
package ru.noties.markwon.html.impl;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import ru.noties.markwon.html.api.HtmlTag;
abstract class HtmlTagImpl implements HtmlTag {
final String name;
final int start;
final Map<String, String> attributes;
int end = NO_END;
protected HtmlTagImpl(@NonNull String name, int start, @NonNull Map<String, String> attributes) {
this.name = name;
this.start = start;
this.attributes = attributes;
}
@NonNull
@Override
public String name() {
return name;
}
@Override
public int start() {
return start;
}
@Override
public int end() {
return end;
}
@Override
public boolean isEmpty() {
return start == end;
}
@NonNull
@Override
public Map<String, String> attributes() {
return attributes;
}
@Override
public boolean isClosed() {
return end > NO_END;
}
abstract void closeAt(int end);
static class InlineImpl extends HtmlTagImpl implements Inline {
InlineImpl(@NonNull String name, int start, @NonNull Map<String, String> attributes) {
super(name, start, attributes);
}
@Override
void closeAt(int end) {
if (!isClosed()) {
super.end = end;
}
}
@Override
public String toString() {
return "InlineImpl{" +
"name='" + name + '\'' +
", start=" + start +
", end=" + end +
", attributes=" + attributes +
'}';
}
@Override
public boolean isInline() {
return true;
}
@Override
public boolean isBlock() {
return false;
}
@NonNull
@Override
public Inline getAsInline() {
return this;
}
@NonNull
@Override
public Block getAsBlock() {
throw new ClassCastException("Cannot cast Inline instance to Block");
}
}
static class BlockImpl extends HtmlTagImpl implements Block {
@NonNull
static BlockImpl root() {
return new BlockImpl("", 0, Collections.<String, String>emptyMap(), null);
}
@NonNull
static BlockImpl create(
@NonNull String name,
int start,
@NonNull Map<String, String> attributes,
@Nullable BlockImpl parent) {
return new BlockImpl(name, start, attributes, parent);
}
final BlockImpl parent;
List<BlockImpl> children;
@SuppressWarnings("NullableProblems")
BlockImpl(
@NonNull String name,
int start,
@NonNull Map<String, String> attributes,
@Nullable BlockImpl parent) {
super(name, start, attributes);
this.parent = parent;
}
@Override
void closeAt(int end) {
if (!isClosed()) {
super.end = end;
if (children != null) {
for (BlockImpl child : children) {
child.closeAt(end);
}
}
}
}
@Override
public boolean isRoot() {
return parent == null;
}
@Nullable
@Override
public Block parent() {
return parent;
}
@NonNull
@Override
public List<Block> children() {
final List<Block> list;
if (children == null) {
list = Collections.emptyList();
} else {
list = Collections.unmodifiableList((List<? extends Block>) children);
}
return list;
}
@NonNull
@Override
public Map<String, String> attributes() {
return attributes;
}
@Override
public boolean isInline() {
return false;
}
@Override
public boolean isBlock() {
return true;
}
@NonNull
@Override
public Inline getAsInline() {
throw new ClassCastException("Cannot cast Block instance to Inline");
}
@NonNull
@Override
public Block getAsBlock() {
return this;
}
@Override
public String toString() {
return "BlockImpl{" +
"name='" + name + '\'' +
", start=" + start +
", end=" + end +
", attributes=" + attributes +
", parent=" + (parent != null ? parent.name : null) +
", children=" + children +
'}';
}
}
}

View File

@ -0,0 +1,486 @@
package ru.noties.markwon.html.impl;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.VisibleForTesting;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import ru.noties.markwon.html.api.HtmlTag;
import ru.noties.markwon.html.api.HtmlTag.Block;
import ru.noties.markwon.html.api.HtmlTag.Inline;
import ru.noties.markwon.html.api.MarkwonHtmlParser;
import ru.noties.markwon.html.impl.jsoup.nodes.Attribute;
import ru.noties.markwon.html.impl.jsoup.nodes.Attributes;
import ru.noties.markwon.html.impl.jsoup.parser.CharacterReader;
import ru.noties.markwon.html.impl.jsoup.parser.ParseErrorList;
import ru.noties.markwon.html.impl.jsoup.parser.Token;
import ru.noties.markwon.html.impl.jsoup.parser.Tokeniser;
import static ru.noties.markwon.html.impl.AppendableUtils.appendQuietly;
/**
* @since 2.0.0
*/
public class MarkwonHtmlParserImpl extends MarkwonHtmlParser {
@NonNull
public static MarkwonHtmlParserImpl create() {
return create(HtmlEmptyTagReplacement.create());
}
@NonNull
public static MarkwonHtmlParserImpl create(@NonNull HtmlEmptyTagReplacement inlineTagReplacement) {
return new MarkwonHtmlParserImpl(inlineTagReplacement, TrimmingAppender.create());
}
// https://developer.mozilla.org/en-US/docs/Web/HTML/Inline_elements
@VisibleForTesting
static final Set<String> INLINE_TAGS;
private static final Set<String> VOID_TAGS;
// these are the tags that are considered _block_ ones
// this parser will ensure that these blocks are started on a new line
// other tags that are NOT inline are considered as block tags, but won't have new line
// inserted before them
// https://developer.mozilla.org/en-US/docs/Web/HTML/Block-level_elements
private static final Set<String> BLOCK_TAGS;
private static final String TAG_PARAGRAPH = "p";
private static final String TAG_LIST_ITEM = "li";
static {
INLINE_TAGS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
"a", "abbr", "acronym",
"b", "bdo", "big", "br", "button",
"cite", "code",
"dfn",
"em",
"i", "img", "input",
"kbd",
"label",
"map",
"object",
"q",
"samp", "script", "select", "small", "span", "strong", "sub", "sup",
"textarea", "time", "tt",
"var"
)));
VOID_TAGS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
"area",
"base", "br",
"col",
"embed",
"hr",
"img", "input",
"keygen",
"link",
"meta",
"param",
"source",
"track",
"wbr"
)));
BLOCK_TAGS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
"address", "article", "aside",
"blockquote",
"canvas",
"dd", "div", "dl", "dt",
"fieldset", "figcaption", "figure", "footer", "form",
"h1", "h2", "h3", "h4", "h5", "h6", "header", "hgroup", "hr",
"li",
"main",
"nav", "noscript",
"ol", "output",
"p", "pre",
"section",
"table", "tfoot",
"ul",
"video"
)));
}
private final HtmlEmptyTagReplacement emptyTagReplacement;
private final TrimmingAppender trimmingAppender;
private final List<HtmlTagImpl.InlineImpl> inlineTags = new ArrayList<>(0);
private HtmlTagImpl.BlockImpl currentBlock = HtmlTagImpl.BlockImpl.root();
private boolean isInsidePreTag;
// the thing is: we ensure a new line BEFORE block tag
// but not after, so another tag will be placed on the same line (which is wrong)
private boolean previousIsBlock;
MarkwonHtmlParserImpl(
@NonNull HtmlEmptyTagReplacement replacement,
@NonNull TrimmingAppender trimmingAppender) {
this.emptyTagReplacement = replacement;
this.trimmingAppender = trimmingAppender;
}
@Override
public <T extends Appendable & CharSequence> void processFragment(
@NonNull T output,
@NonNull String htmlFragment) {
// we might want to reuse tokeniser (at least when the same output is involved)
// as CharacterReader does a bit of initialization (cache etc) as it's
// primary usage is parsing a document in one run (not parsing _fragments_)
final Tokeniser tokeniser = new Tokeniser(new CharacterReader(htmlFragment), ParseErrorList.noTracking());
while (true) {
final Token token = tokeniser.read();
final Token.TokenType tokenType = token.type;
if (Token.TokenType.EOF == tokenType) {
break;
}
switch (tokenType) {
case StartTag: {
final Token.StartTag startTag = (Token.StartTag) token;
if (isInlineTag(startTag.normalName)) {
processInlineTagStart(output, startTag);
} else {
processBlockTagStart(output, startTag);
}
}
break;
case EndTag: {
final Token.EndTag endTag = (Token.EndTag) token;
if (isInlineTag(endTag.normalName)) {
processInlineTagEnd(output, endTag);
} else {
processBlockTagEnd(output, endTag);
}
}
break;
case Character: {
processCharacter(output, ((Token.Character) token));
}
break;
}
// do not forget to reset processed token (even if it's not processed)
token.reset();
}
}
@Override
public void flushInlineTags(int documentLength, @NonNull FlushAction<Inline> action) {
if (inlineTags.size() > 0) {
if (documentLength > HtmlTag.NO_END) {
for (HtmlTagImpl.InlineImpl inline : inlineTags) {
inline.closeAt(documentLength);
}
}
//noinspection unchecked
action.apply(Collections.unmodifiableList((List<? extends Inline>) inlineTags));
inlineTags.clear();
} else {
action.apply(Collections.<Inline>emptyList());
}
}
@Override
public void flushBlockTags(int documentLength, @NonNull FlushAction<Block> action) {
HtmlTagImpl.BlockImpl block = currentBlock;
while (block.parent != null) {
block = block.parent;
}
if (documentLength > HtmlTag.NO_END) {
block.closeAt(documentLength);
}
final List<Block> children = block.children();
if (children.size() > 0) {
action.apply(children);
} else {
action.apply(Collections.<Block>emptyList());
}
currentBlock = HtmlTagImpl.BlockImpl.root();
}
@Override
public void reset() {
inlineTags.clear();
currentBlock = HtmlTagImpl.BlockImpl.root();
}
protected <T extends Appendable & CharSequence> void processInlineTagStart(
@NonNull T output,
@NonNull Token.StartTag startTag) {
final String name = startTag.normalName;
final HtmlTagImpl.InlineImpl inline = new HtmlTagImpl.InlineImpl(name, output.length(), extractAttributes(startTag));
ensureNewLineIfPreviousWasBlock(output);
if (isVoidTag(name)
|| startTag.selfClosing) {
final String replacement = emptyTagReplacement.replace(inline);
if (replacement != null
&& replacement.length() > 0) {
appendQuietly(output, replacement);
}
// the thing is: we will keep this inline tag in the list,
// but in case of void-tag that has no replacement, there will be no
// possibility to set a span (requires at least one char)
inline.closeAt(output.length());
}
inlineTags.add(inline);
}
protected <T extends Appendable & CharSequence> void processInlineTagEnd(
@NonNull T output,
@NonNull Token.EndTag endTag) {
// try to find it, if none found -> ignore
final HtmlTagImpl.InlineImpl openInline = findOpenInlineTag(endTag.normalName);
if (openInline != null) {
// okay, if this tag is empty -> call replacement
if (isEmpty(output, openInline)) {
appendEmptyTagReplacement(output, openInline);
}
// close open inline tag
openInline.closeAt(output.length());
}
}
protected <T extends Appendable & CharSequence> void processBlockTagStart(
@NonNull T output,
@NonNull Token.StartTag startTag) {
final String name = startTag.normalName;
// block tags (all that are NOT inline -> blocks
// there is only one strong rule -> paragraph cannot contain anything
// except inline tags
if (TAG_PARAGRAPH.equals(currentBlock.name)) {
// it must be closed here not matter what we are as here we _assume_
// that it's a block tag
currentBlock.closeAt(output.length());
appendQuietly(output, '\n');
currentBlock = currentBlock.parent;
} else if (TAG_LIST_ITEM.equals(name)
&& TAG_LIST_ITEM.equals(currentBlock.name)) {
// close previous list item if in the same parent
currentBlock.closeAt(output.length());
currentBlock = currentBlock.parent;
}
if (isBlockTag(name)) {
isInsidePreTag = "pre".equals(name);
ensureNewLine(output);
} else {
ensureNewLineIfPreviousWasBlock(output);
}
final int start = output.length();
final HtmlTagImpl.BlockImpl block = HtmlTagImpl.BlockImpl.create(name, start, extractAttributes(startTag), currentBlock);
final boolean isVoid = isVoidTag(name) || startTag.selfClosing;
if (isVoid) {
final String replacement = emptyTagReplacement.replace(block);
if (replacement != null
&& replacement.length() > 0) {
appendQuietly(output, replacement);
}
block.closeAt(output.length());
}
//noinspection ConstantConditions
appendBlockChild(block.parent, block);
// if not void start filling-in children
if (!isVoid) {
this.currentBlock = block;
}
}
protected <T extends Appendable & CharSequence> void processBlockTagEnd(
@NonNull T output,
@NonNull Token.EndTag endTag) {
final String name = endTag.normalName;
final HtmlTagImpl.BlockImpl block = findOpenBlockTag(endTag.normalName);
if (block != null) {
if ("pre".equals(name)) {
isInsidePreTag = false;
}
// okay, if this tag is empty -> call replacement
if (isEmpty(output, block)) {
appendEmptyTagReplacement(output, block);
}
block.closeAt(output.length());
// if it's empty -> we do no care about if it's block or not
if (!block.isEmpty()) {
previousIsBlock = isBlockTag(block.name);
}
if (TAG_PARAGRAPH.equals(name)) {
appendQuietly(output, '\n');
}
this.currentBlock = block.parent;
}
}
protected <T extends Appendable & CharSequence> void processCharacter(
@NonNull T output,
@NonNull Token.Character character) {
// there are tags: BUTTON, INPUT, SELECT, SCRIPT, TEXTAREA, STYLE
// that might have character data that we do not want to display
if (isInsidePreTag) {
appendQuietly(output, character.getData());
} else {
ensureNewLineIfPreviousWasBlock(output);
trimmingAppender.append(output, character.getData());
}
}
protected void appendBlockChild(@NonNull HtmlTagImpl.BlockImpl parent, @NonNull HtmlTagImpl.BlockImpl child) {
List<HtmlTagImpl.BlockImpl> children = parent.children;
if (children == null) {
children = new ArrayList<>(2);
parent.children = children;
}
children.add(child);
}
@Nullable
protected HtmlTagImpl.InlineImpl findOpenInlineTag(@NonNull String name) {
HtmlTagImpl.InlineImpl inline;
for (int i = inlineTags.size() - 1; i > -1; i--) {
inline = inlineTags.get(i);
if (name.equals(inline.name)
&& inline.end < 0) {
return inline;
}
}
return null;
}
@Nullable
protected HtmlTagImpl.BlockImpl findOpenBlockTag(@NonNull String name) {
HtmlTagImpl.BlockImpl blockTag = currentBlock;
while (blockTag != null
&& !name.equals(blockTag.name) && !blockTag.isClosed()) {
blockTag = blockTag.parent;
}
return blockTag;
}
protected <T extends Appendable & CharSequence> void ensureNewLineIfPreviousWasBlock(@NonNull T output) {
if (previousIsBlock) {
ensureNewLine(output);
previousIsBlock = false;
}
}
// name here must lower case
protected static boolean isInlineTag(@NonNull String name) {
return INLINE_TAGS.contains(name);
}
protected static boolean isVoidTag(@NonNull String name) {
return VOID_TAGS.contains(name);
}
protected static boolean isBlockTag(@NonNull String name) {
return BLOCK_TAGS.contains(name);
}
protected static <T extends Appendable & CharSequence> void ensureNewLine(@NonNull T output) {
final int length = output.length();
if (length > 0
&& '\n' != output.charAt(length - 1)) {
appendQuietly(output, '\n');
}
}
@NonNull
protected static Map<String, String> extractAttributes(@NonNull Token.StartTag startTag) {
Map<String, String> map;
final Attributes attributes = startTag.attributes;
final int size = attributes.size();
if (size > 0) {
map = new HashMap<>(size);
for (Attribute attribute : attributes) {
map.put(attribute.getKey().toLowerCase(Locale.US), attribute.getValue());
}
map = Collections.unmodifiableMap(map);
} else {
map = Collections.emptyMap();
}
return map;
}
protected static <T extends Appendable & CharSequence> boolean isEmpty(
@NonNull T output,
@NonNull HtmlTagImpl tag) {
return tag.start == output.length();
}
protected <T extends Appendable & CharSequence> void appendEmptyTagReplacement(
@NonNull T output,
@NonNull HtmlTagImpl tag) {
final String replacement = emptyTagReplacement.replace(tag);
if (replacement != null) {
appendQuietly(output, replacement);
}
}
}

View File

@ -0,0 +1,68 @@
package ru.noties.markwon.html.impl;
import android.support.annotation.NonNull;
import static ru.noties.markwon.html.impl.AppendableUtils.appendQuietly;
abstract class TrimmingAppender {
abstract <T extends Appendable & CharSequence> void append(
@NonNull T output,
@NonNull String data
);
@NonNull
static TrimmingAppender create() {
return new Impl();
}
static class Impl extends TrimmingAppender {
// if data is fully empty (consists of white spaces) -> do not add anything
// leading ws:
// - trim to one space (if at all present) append to output only if previous is ws
// trailing ws:
// - if present trim to single space
@Override
<T extends Appendable & CharSequence> void append(
@NonNull T output,
@NonNull String data
) {
final int startLength = output.length();
char c;
boolean previousIsWhiteSpace = false;
for (int i = 0, length = data.length(); i < length; i++) {
c = data.charAt(i);
if (Character.isWhitespace(c)) {
previousIsWhiteSpace = true;
continue;
}
if (previousIsWhiteSpace) {
// validate that output has ws as last char
final int outputLength = output.length();
if (outputLength > 0
&& !Character.isWhitespace(output.charAt(outputLength - 1))) {
appendQuietly(output, ' ');
}
}
previousIsWhiteSpace = false;
appendQuietly(output, c);
}
// additionally check if previousIsWhiteSpace is true (if data ended with ws)
// BUT only if we have added something (otherwise the whole data is empty (white))
if (previousIsWhiteSpace && (startLength < output.length())) {
appendQuietly(output, ' ');
}
}
}
}

View File

@ -0,0 +1,13 @@
package ru.noties.markwon.html.impl.jsoup;
import java.io.IOException;
public class UncheckedIOException extends RuntimeException {
public UncheckedIOException(IOException cause) {
super(cause);
}
public IOException ioException() {
return (IOException) getCause();
}
}

View File

@ -0,0 +1,18 @@
package ru.noties.markwon.html.impl.jsoup.helper;
import java.util.Locale;
/**
* Util methods for normalizing strings. Jsoup internal use only, please don't depend on this API.
*/
public final class Normalizer {
public static String lowerCase(final String input) {
return input != null ? input.toLowerCase(Locale.ENGLISH) : "";
}
public static String normalize(final String input) {
return lowerCase(input).trim();
}
}

View File

@ -0,0 +1,112 @@
package ru.noties.markwon.html.impl.jsoup.helper;
/**
* Simple validation methods. Designed for jsoup internal use
*/
public final class Validate {
private Validate() {}
/**
* Validates that the object is not null
* @param obj object to test
*/
public static void notNull(Object obj) {
if (obj == null)
throw new IllegalArgumentException("Object must not be null");
}
/**
* Validates that the object is not null
* @param obj object to test
* @param msg message to output if validation fails
*/
public static void notNull(Object obj, String msg) {
if (obj == null)
throw new IllegalArgumentException(msg);
}
/**
* Validates that the value is true
* @param val object to test
*/
public static void isTrue(boolean val) {
if (!val)
throw new IllegalArgumentException("Must be true");
}
/**
* Validates that the value is true
* @param val object to test
* @param msg message to output if validation fails
*/
public static void isTrue(boolean val, String msg) {
if (!val)
throw new IllegalArgumentException(msg);
}
/**
* Validates that the value is false
* @param val object to test
*/
public static void isFalse(boolean val) {
if (val)
throw new IllegalArgumentException("Must be false");
}
/**
* Validates that the value is false
* @param val object to test
* @param msg message to output if validation fails
*/
public static void isFalse(boolean val, String msg) {
if (val)
throw new IllegalArgumentException(msg);
}
/**
* Validates that the array contains no null elements
* @param objects the array to test
*/
public static void noNullElements(Object[] objects) {
noNullElements(objects, "Array must not contain any null objects");
}
/**
* Validates that the array contains no null elements
* @param objects the array to test
* @param msg message to output if validation fails
*/
public static void noNullElements(Object[] objects, String msg) {
for (Object obj : objects)
if (obj == null)
throw new IllegalArgumentException(msg);
}
/**
* Validates that the string is not empty
* @param string the string to test
*/
public static void notEmpty(String string) {
if (string == null || string.length() == 0)
throw new IllegalArgumentException("String must not be empty");
}
/**
* Validates that the string is not empty
* @param string the string to test
* @param msg message to output if validation fails
*/
public static void notEmpty(String string, String msg) {
if (string == null || string.length() == 0)
throw new IllegalArgumentException(msg);
}
/**
Cause a failure.
@param msg message to output.
*/
public static void fail(String msg) {
throw new IllegalArgumentException(msg);
}
}

View File

@ -0,0 +1,202 @@
package ru.noties.markwon.html.impl.jsoup.nodes;
import java.util.Map;
import ru.noties.markwon.html.impl.jsoup.helper.Validate;
/**
A single key + value attribute. (Only used for presentation.)
*/
public class Attribute implements Map.Entry<String, String>, Cloneable {
// private static final String[] booleanAttributes = {
// "allowfullscreen", "async", "autofocus", "checked", "compact", "declare", "default", "defer", "disabled",
// "formnovalidate", "hidden", "inert", "ismap", "itemscope", "multiple", "muted", "nohref", "noresize",
// "noshade", "novalidate", "nowrap", "open", "readonly", "required", "reversed", "seamless", "selected",
// "sortable", "truespeed", "typemustmatch"
// };
private String key;
private String val;
Attributes parent; // used to update the holding Attributes when the key / value is changed via this interface
/**
* Create a new attribute from unencoded (raw) key and value.
* @param key attribute key; case is preserved.
* @param value attribute value
*/
public Attribute(String key, String value) {
this(key, value, null);
}
/**
* Create a new attribute from unencoded (raw) key and value.
* @param key attribute key; case is preserved.
* @param val attribute value
* @param parent the containing Attributes (this Attribute is not automatically added to said Attributes)
*/
public Attribute(String key, String val, Attributes parent) {
Validate.notNull(key);
this.key = key.trim();
Validate.notEmpty(key); // trimming could potentially make empty, so validate here
this.val = val;
this.parent = parent;
}
/**
Get the attribute key.
@return the attribute key
*/
public String getKey() {
return key;
}
/**
Set the attribute key; case is preserved.
@param key the new key; must not be null
*/
public void setKey(String key) {
Validate.notNull(key);
key = key.trim();
Validate.notEmpty(key); // trimming could potentially make empty, so validate here
if (parent != null) {
int i = parent.indexOfKey(this.key);
if (i != Attributes.NotFound)
parent.keys[i] = key;
}
this.key = key;
}
/**
Get the attribute value.
@return the attribute value
*/
public String getValue() {
return val;
}
/**
Set the attribute value.
@param val the new attribute value; must not be null
*/
public String setValue(String val) {
String oldVal = parent.get(this.key);
if (parent != null) {
int i = parent.indexOfKey(this.key);
if (i != Attributes.NotFound)
parent.vals[i] = val;
}
this.val = val;
return oldVal;
}
// /**
// Get the HTML representation of this attribute; e.g. {@code href="index.html"}.
// @return HTML
// */
// public String html() {
// StringBuilder accum = new StringBuilder();
//
// try {
// html(accum, (new Document("")).outputSettings());
// } catch(IOException exception) {
// throw new SerializationException(exception);
// }
// return accum.toString();
// }
//
// protected static void html(String key, String val, Appendable accum, Document.OutputSettings out) throws IOException {
// accum.append(key);
// if (!shouldCollapseAttribute(key, val, out)) {
// accum.append("=\"");
// Entities.escape(accum, Attributes.checkNotNull(val) , out, true, false, false);
// accum.append('"');
// }
// }
//
// protected void html(Appendable accum, Document.OutputSettings out) throws IOException {
// html(key, val, accum, out);
// }
// /**
// Get the string representation of this attribute, implemented as {@link #html()}.
// @return string
// */
// @Override
// public String toString() {
// return html();
// }
// /**
// * Create a new Attribute from an unencoded key and a HTML attribute encoded value.
// * @param unencodedKey assumes the key is not encoded, as can be only run of simple \w chars.
// * @param encodedValue HTML attribute encoded value
// * @return attribute
// */
// public static Attribute createFromEncoded(String unencodedKey, String encodedValue) {
// String value = Entities.unescape(encodedValue, true);
// return new Attribute(unencodedKey, value, null); // parent will get set when Put
// }
// protected boolean isDataAttribute() {
// return isDataAttribute(key);
// }
//
// protected static boolean isDataAttribute(String key) {
// return key.startsWith(Attributes.dataPrefix) && key.length() > Attributes.dataPrefix.length();
// }
// /**
// * Collapsible if it's a boolean attribute and value is empty or same as name
// *
// * @param out output settings
// * @return Returns whether collapsible or not
// */
// protected final boolean shouldCollapseAttribute(Document.OutputSettings out) {
// return shouldCollapseAttribute(key, val, out);
// }
// protected static boolean shouldCollapseAttribute(final String key, final String val, final Document.OutputSettings out) {
// return (
// out.syntax() == Document.OutputSettings.Syntax.html &&
// (val == null || ("".equals(val) || val.equalsIgnoreCase(key)) && Attribute.isBooleanAttribute(key)));
// }
// /**
// * @deprecated
// */
// protected boolean isBooleanAttribute() {
// return Arrays.binarySearch(booleanAttributes, key) >= 0 || val == null;
// }
//
// /**
// * Checks if this attribute name is defined as a boolean attribute in HTML5
// */
// protected static boolean isBooleanAttribute(final String key) {
// return Arrays.binarySearch(booleanAttributes, key) >= 0;
// }
@Override
public boolean equals(Object o) { // note parent not considered
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Attribute attribute = (Attribute) o;
if (key != null ? !key.equals(attribute.key) : attribute.key != null) return false;
return val != null ? val.equals(attribute.val) : attribute.val == null;
}
@Override
public int hashCode() { // note parent not considered
int result = key != null ? key.hashCode() : 0;
result = 31 * result + (val != null ? val.hashCode() : 0);
return result;
}
@Override
public Attribute clone() {
try {
return (Attribute) super.clone();
} catch (CloneNotSupportedException e) {
throw new RuntimeException(e);
}
}
}

View File

@ -0,0 +1,441 @@
package ru.noties.markwon.html.impl.jsoup.nodes;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import ru.noties.markwon.html.impl.jsoup.helper.Validate;
import static ru.noties.markwon.html.impl.jsoup.helper.Normalizer.lowerCase;
/**
* The attributes of an Element.
* <p>
* Attributes are treated as a map: there can be only one value associated with an attribute key/name.
* </p>
* <p>
* Attribute name and value comparisons are generally <b>case sensitive</b>. By default for HTML, attribute names are
* normalized to lower-case on parsing. That means you should use lower-case strings when referring to attributes by
* name.
* </p>
*
* @author Jonathan Hedley, jonathan@hedley.net
*/
public class Attributes implements Iterable<Attribute>, Cloneable {
// protected static final String dataPrefix = "data-";
private static final int InitialCapacity = 4; // todo - analyze Alexa 1MM sites, determine best setting
// manages the key/val arrays
private static final int GrowthFactor = 2;
private static final String[] Empty = {};
static final int NotFound = -1;
private static final String EmptyString = "";
private int size = 0; // number of slots used (not capacity, which is keys.length
String[] keys = Empty;
String[] vals = Empty;
// check there's room for more
private void checkCapacity(int minNewSize) {
Validate.isTrue(minNewSize >= size);
int curSize = keys.length;
if (curSize >= minNewSize)
return;
int newSize = curSize >= InitialCapacity ? size * GrowthFactor : InitialCapacity;
if (minNewSize > newSize)
newSize = minNewSize;
keys = copyOf(keys, newSize);
vals = copyOf(vals, newSize);
}
// simple implementation of Arrays.copy, for support of Android API 8.
private static String[] copyOf(String[] orig, int size) {
final String[] copy = new String[size];
System.arraycopy(orig, 0, copy, 0,
Math.min(orig.length, size));
return copy;
}
int indexOfKey(String key) {
Validate.notNull(key);
for (int i = 0; i < size; i++) {
if (key.equals(keys[i]))
return i;
}
return NotFound;
}
private int indexOfKeyIgnoreCase(String key) {
Validate.notNull(key);
for (int i = 0; i < size; i++) {
if (key.equalsIgnoreCase(keys[i]))
return i;
}
return NotFound;
}
// we track boolean attributes as null in values - they're just keys. so returns empty for consumers
static String checkNotNull(String val) {
return val == null ? EmptyString : val;
}
/**
Get an attribute value by key.
@param key the (case-sensitive) attribute key
@return the attribute value if set; or empty string if not set (or a boolean attribute).
@see #hasKey(String)
*/
public String get(String key) {
int i = indexOfKey(key);
return i == NotFound ? EmptyString : checkNotNull(vals[i]);
}
/**
* Get an attribute's value by case-insensitive key
* @param key the attribute name
* @return the first matching attribute value if set; or empty string if not set (ora boolean attribute).
*/
public String getIgnoreCase(String key) {
int i = indexOfKeyIgnoreCase(key);
return i == NotFound ? EmptyString : checkNotNull(vals[i]);
}
// adds without checking if this key exists
private void add(String key, String value) {
checkCapacity(size + 1);
keys[size] = key;
vals[size] = value;
size++;
}
/**
* Set a new attribute, or replace an existing one by key.
* @param key case sensitive attribute key
* @param value attribute value
* @return these attributes, for chaining
*/
public Attributes put(String key, String value) {
int i = indexOfKey(key);
if (i != NotFound)
vals[i] = value;
else
add(key, value);
return this;
}
void putIgnoreCase(String key, String value) {
int i = indexOfKeyIgnoreCase(key);
if (i != NotFound) {
vals[i] = value;
if (!keys[i].equals(key)) // case changed, update
keys[i] = key;
}
else
add(key, value);
}
/**
* Set a new boolean attribute, remove attribute if value is false.
* @param key case <b>insensitive</b> attribute key
* @param value attribute value
* @return these attributes, for chaining
*/
public Attributes put(String key, boolean value) {
if (value)
putIgnoreCase(key, null);
else
remove(key);
return this;
}
/**
Set a new attribute, or replace an existing one by key.
@param attribute attribute with case sensitive key
@return these attributes, for chaining
*/
public Attributes put(Attribute attribute) {
Validate.notNull(attribute);
put(attribute.getKey(), attribute.getValue());
attribute.parent = this;
return this;
}
// removes and shifts up
private void remove(int index) {
Validate.isFalse(index >= size);
int shifted = size - index - 1;
if (shifted > 0) {
System.arraycopy(keys, index + 1, keys, index, shifted);
System.arraycopy(vals, index + 1, vals, index, shifted);
}
size--;
keys[size] = null; // release hold
vals[size] = null;
}
/**
Remove an attribute by key. <b>Case sensitive.</b>
@param key attribute key to remove
*/
public void remove(String key) {
int i = indexOfKey(key);
if (i != NotFound)
remove(i);
}
/**
Remove an attribute by key. <b>Case insensitive.</b>
@param key attribute key to remove
*/
public void removeIgnoreCase(String key) {
int i = indexOfKeyIgnoreCase(key);
if (i != NotFound)
remove(i);
}
/**
Tests if these attributes contain an attribute with this key.
@param key case-sensitive key to check for
@return true if key exists, false otherwise
*/
public boolean hasKey(String key) {
return indexOfKey(key) != NotFound;
}
/**
Tests if these attributes contain an attribute with this key.
@param key key to check for
@return true if key exists, false otherwise
*/
public boolean hasKeyIgnoreCase(String key) {
return indexOfKeyIgnoreCase(key) != NotFound;
}
/**
Get the number of attributes in this set.
@return size
*/
public int size() {
return size;
}
/**
Add all the attributes from the incoming set to this set.
@param incoming attributes to add to these attributes.
*/
public void addAll(Attributes incoming) {
if (incoming.size() == 0)
return;
checkCapacity(size + incoming.size);
for (Attribute attr : incoming) {
// todo - should this be case insensitive?
put(attr);
}
}
public Iterator<Attribute> iterator() {
return new Iterator<Attribute>() {
int i = 0;
@Override
public boolean hasNext() {
return i < size;
}
@Override
public Attribute next() {
final String val = vals[i];
final Attribute attr = new Attribute(keys[i], val == null ? "" : val, Attributes.this);
i++;
return attr;
}
@Override
public void remove() {
Attributes.this.remove(--i); // next() advanced, so rewind
}
};
}
// /**
// Get the attributes as a List, for iteration.
// @return an view of the attributes as an unmodifialbe List.
// */
// public List<Attribute> asList() {
// ArrayList<Attribute> list = new ArrayList<>(size);
// for (int i = 0; i < size; i++) {
//// Attribute attr = vals[i] == null ?
//// new BooleanAttribute(keys[i]) : // deprecated class, but maybe someone still wants it
//// new Attribute(keys[i], vals[i], Attributes.this);
//// list.add(attr);
// list.add(new Attribute(keys[i], vals[i], Attributes.this));
// }
// return Collections.unmodifiableList(list);
// }
// /**
// * Retrieves a filtered view of attributes that are HTML5 custom data attributes; that is, attributes with keys
// * starting with {@code data-}.
// * @return map of custom data attributes.
// */
// public Map<String, String> dataset() {
// return new Dataset(this);
// }
// /**
// Get the HTML representation of these attributes.
// @return HTML
// @throws SerializationException if the HTML representation of the attributes cannot be constructed.
// */
// public String html() {
// StringBuilder accum = new StringBuilder();
// try {
// html(accum, (new Document("")).outputSettings()); // output settings a bit funky, but this html() seldom used
// } catch (IOException e) { // ought never happen
// throw new SerializationException(e);
// }
// return accum.toString();
// }
//
// final void html(final Appendable accum, final Document.OutputSettings out) throws IOException {
// final int sz = size;
// for (int i = 0; i < sz; i++) {
// // inlined from Attribute.html()
// final String key = keys[i];
// final String val = vals[i];
// accum.append(' ').append(key);
//
// // collapse checked=null, checked="", checked=checked; write out others
// if (!Attribute.shouldCollapseAttribute(key, val, out)) {
// accum.append("=\"");
// Entities.escape(accum, val == null ? EmptyString : val, out, true, false, false);
// accum.append('"');
// }
// }
// }
//
// @Override
// public String toString() {
// return html();
// }
/**
* Checks if these attributes are equal to another set of attributes, by comparing the two sets
* @param o attributes to compare with
* @return if both sets of attributes have the same content
*/
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Attributes that = (Attributes) o;
if (size != that.size) return false;
if (!Arrays.equals(keys, that.keys)) return false;
return Arrays.equals(vals, that.vals);
}
/**
* Calculates the hashcode of these attributes, by iterating all attributes and summing their hashcodes.
* @return calculated hashcode
*/
@Override
public int hashCode() {
int result = size;
result = 31 * result + Arrays.hashCode(keys);
result = 31 * result + Arrays.hashCode(vals);
return result;
}
@Override
public Attributes clone() {
Attributes clone;
try {
clone = (Attributes) super.clone();
} catch (CloneNotSupportedException e) {
throw new RuntimeException(e);
}
clone.size = size;
keys = copyOf(keys, size);
vals = copyOf(vals, size);
return clone;
}
/**
* Internal method. Lowercases all keys.
*/
public void normalize() {
for (int i = 0; i < size; i++) {
keys[i] = lowerCase(keys[i]);
}
}
// private static class Dataset extends AbstractMap<String, String> {
// private final Attributes attributes;
//
// private Dataset(Attributes attributes) {
// this.attributes = attributes;
// }
//
// @Override
// public Set<Entry<String, String>> entrySet() {
// return new EntrySet();
// }
//
// @Override
// public String put(String key, String value) {
// String dataKey = dataKey(key);
// String oldValue = attributes.hasKey(dataKey) ? attributes.get(dataKey) : null;
// attributes.put(dataKey, value);
// return oldValue;
// }
//
// private class EntrySet extends AbstractSet<Map.Entry<String, String>> {
//
// @Override
// public Iterator<Map.Entry<String, String>> iterator() {
// return new DatasetIterator();
// }
//
// @Override
// public int size() {
// int count = 0;
// Iterator iter = new DatasetIterator();
// while (iter.hasNext())
// count++;
// return count;
// }
// }
//
// private class DatasetIterator implements Iterator<Map.Entry<String, String>> {
// private Iterator<Attribute> attrIter = attributes.iterator();
// private Attribute attr;
// public boolean hasNext() {
// while (attrIter.hasNext()) {
// attr = attrIter.next();
// if (attr.isDataAttribute()) return true;
// }
// return false;
// }
//
// public Entry<String, String> next() {
// return new Attribute(attr.getKey().substring(dataPrefix.length()), attr.getValue());
// }
//
// public void remove() {
// attributes.remove(attr.getKey());
// }
// }
// }
// private static String dataKey(String key) {
// return dataPrefix + key;
// }
}

View File

@ -0,0 +1,50 @@
package ru.noties.markwon.html.impl.jsoup.nodes;
import android.support.annotation.NonNull;
import org.commonmark.internal.util.Html5Entities;
import java.lang.reflect.Field;
import java.util.Collections;
import java.util.Map;
public abstract class CommonMarkEntities {
public static boolean isNamedEntity(@NonNull String name) {
return COMMONMARK_NAMED_ENTITIES.containsKey(name);
}
public static int codepointsForName(@NonNull String name, @NonNull int[] codepoints) {
final String value = COMMONMARK_NAMED_ENTITIES.get(name);
if (value != null) {
final int length = value.length();
if (length == 1) {
codepoints[0] = value.charAt(0);
} else {
codepoints[0] = value.charAt(0);
codepoints[1] = value.charAt(1);
}
return length;
}
return 0;
}
private static final Map<String, String> COMMONMARK_NAMED_ENTITIES;
static {
Map<String, String> map;
try {
final Field field = Html5Entities.class.getDeclaredField("NAMED_CHARACTER_REFERENCES");
field.setAccessible(true);
//noinspection unchecked
map = (Map<String, String>) field.get(null);
} catch (Throwable t) {
map = Collections.emptyMap();
t.printStackTrace();
}
COMMONMARK_NAMED_ENTITIES = map;
}
private CommonMarkEntities() {
}
}

View File

@ -0,0 +1,104 @@
package ru.noties.markwon.html.impl.jsoup.nodes;
/**
* A {@code <!DOCTYPE>} node.
*/
public class DocumentType /*extends LeafNode*/ {
// todo needs a bit of a chunky cleanup. this level of detail isn't needed
public static final String PUBLIC_KEY = "PUBLIC";
public static final String SYSTEM_KEY = "SYSTEM";
// private static final String NAME = "name";
// private static final String PUB_SYS_KEY = "pubSysKey"; // PUBLIC or SYSTEM
// private static final String PUBLIC_ID = "publicId";
// private static final String SYSTEM_ID = "systemId";
// todo: quirk mode from publicId and systemId
// /**
// * Create a new doctype element.
// * @param name the doctype's name
// * @param publicId the doctype's public ID
// * @param systemId the doctype's system ID
// */
// public DocumentType(String name, String publicId, String systemId) {
// Validate.notNull(name);
// Validate.notNull(publicId);
// Validate.notNull(systemId);
// attr(NAME, name);
// attr(PUBLIC_ID, publicId);
// if (has(PUBLIC_ID)) {
// attr(PUB_SYS_KEY, PUBLIC_KEY);
// }
// attr(SYSTEM_ID, systemId);
// }
//
// /**
// * Create a new doctype element.
// * @param name the doctype's name
// * @param publicId the doctype's public ID
// * @param systemId the doctype's system ID
// * @param baseUri unused
// * @deprecated
// */
// public DocumentType(String name, String publicId, String systemId, String baseUri) {
// attr(NAME, name);
// attr(PUBLIC_ID, publicId);
// if (has(PUBLIC_ID)) {
// attr(PUB_SYS_KEY, PUBLIC_KEY);
// }
// attr(SYSTEM_ID, systemId);
// }
//
// /**
// * Create a new doctype element.
// * @param name the doctype's name
// * @param publicId the doctype's public ID
// * @param systemId the doctype's system ID
// * @param baseUri unused
// * @deprecated
// */
// public DocumentType(String name, String pubSysKey, String publicId, String systemId, String baseUri) {
// attr(NAME, name);
// if (pubSysKey != null) {
// attr(PUB_SYS_KEY, pubSysKey);
// }
// attr(PUBLIC_ID, publicId);
// attr(SYSTEM_ID, systemId);
// }
// public void setPubSysKey(String value) {
// if (value != null)
// attr(PUB_SYS_KEY, value);
// }
//
// @Override
// public String nodeName() {
// return "#doctype";
// }
//
// @Override
// void outerHtmlHead(Appendable accum, int depth, Document.OutputSettings out) throws IOException {
// if (out.syntax() == Syntax.html && !has(PUBLIC_ID) && !has(SYSTEM_ID)) {
// // looks like a html5 doctype, go lowercase for aesthetics
// accum.append("<!doctype");
// } else {
// accum.append("<!DOCTYPE");
// }
// if (has(NAME))
// accum.append(" ").append(attr(NAME));
// if (has(PUB_SYS_KEY))
// accum.append(" ").append(attr(PUB_SYS_KEY));
// if (has(PUBLIC_ID))
// accum.append(" \"").append(attr(PUBLIC_ID)).append('"');
// if (has(SYSTEM_ID))
// accum.append(" \"").append(attr(SYSTEM_ID)).append('"');
// accum.append('>');
// }
//
// @Override
// void outerHtmlTail(Appendable accum, int depth, Document.OutputSettings out) {
// }
//
// private boolean has(final String attribute) {
// return !StringUtil.isBlank(attr(attribute));
// }
}

View File

@ -0,0 +1,501 @@
package ru.noties.markwon.html.impl.jsoup.parser;
import android.support.annotation.NonNull;
import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.util.Arrays;
import java.util.Locale;
import ru.noties.markwon.html.impl.jsoup.UncheckedIOException;
import ru.noties.markwon.html.impl.jsoup.helper.Validate;
/**
* CharacterReader consumes tokens off a string. Used internally by jsoup. API subject to changes.
*/
public final class CharacterReader {
static final char EOF = (char) -1;
private static final int maxStringCacheLen = 12;
static final int maxBufferLen = 1024 * 4; // visible for testing
private static final int readAheadLimit = (int) (maxBufferLen * 0.75);
private final char[] charBuf;
private final Reader reader;
private int bufLength;
private int bufSplitPoint;
private int bufPos;
private int readerPos;
private int bufMark;
private final String[] stringCache = new String[128]; // holds reused strings in this doc, to lessen garbage
public CharacterReader(Reader input, int sz) {
Validate.notNull(input);
Validate.isTrue(input.markSupported());
reader = input;
charBuf = new char[maxBufferLen];
bufferUp();
}
public CharacterReader(Reader input) {
this(input, maxBufferLen);
}
public CharacterReader(String input) {
this(new StringReader(input), input.length());
}
// public void swapInput(@NonNull String input) {
// reader = new StringReader(input);
// bufLength = 0;
// bufSplitPoint = 0;
// bufPos = 0;
// readerPos = 0;
// bufferUp();
// }
private void bufferUp() {
if (bufPos < bufSplitPoint)
return;
try {
reader.skip(bufPos);
reader.mark(maxBufferLen);
final int read = reader.read(charBuf);
reader.reset();
if (read != -1) {
bufLength = read;
readerPos += bufPos;
bufPos = 0;
bufMark = 0;
bufSplitPoint = bufLength > readAheadLimit ? readAheadLimit : bufLength;
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
/**
* Gets the current cursor position in the content.
*
* @return current position
*/
public int pos() {
return readerPos + bufPos;
}
/**
* Tests if all the content has been read.
*
* @return true if nothing left to read.
*/
public boolean isEmpty() {
bufferUp();
return bufPos >= bufLength;
}
private boolean isEmptyNoBufferUp() {
return bufPos >= bufLength;
}
/**
* Get the char at the current position.
*
* @return char
*/
public char current() {
bufferUp();
return isEmptyNoBufferUp() ? EOF : charBuf[bufPos];
}
char consume() {
bufferUp();
char val = isEmptyNoBufferUp() ? EOF : charBuf[bufPos];
bufPos++;
return val;
}
void unconsume() {
bufPos--;
}
/**
* Moves the current position by one.
*/
public void advance() {
bufPos++;
}
void mark() {
bufMark = bufPos;
}
void rewindToMark() {
bufPos = bufMark;
}
/**
* Returns the number of characters between the current position and the next instance of the input char
*
* @param c scan target
* @return offset between current position and next instance of target. -1 if not found.
*/
int nextIndexOf(char c) {
// doesn't handle scanning for surrogates
bufferUp();
for (int i = bufPos; i < bufLength; i++) {
if (c == charBuf[i])
return i - bufPos;
}
return -1;
}
/**
* Returns the number of characters between the current position and the next instance of the input sequence
*
* @param seq scan target
* @return offset between current position and next instance of target. -1 if not found.
*/
int nextIndexOf(CharSequence seq) {
bufferUp();
// doesn't handle scanning for surrogates
char startChar = seq.charAt(0);
for (int offset = bufPos; offset < bufLength; offset++) {
// scan to first instance of startchar:
if (startChar != charBuf[offset])
while (++offset < bufLength && startChar != charBuf[offset]) { /* empty */ }
int i = offset + 1;
int last = i + seq.length() - 1;
if (offset < bufLength && last <= bufLength) {
for (int j = 1; i < last && seq.charAt(j) == charBuf[i]; i++, j++) { /* empty */ }
if (i == last) // found full sequence
return offset - bufPos;
}
}
return -1;
}
/**
* Reads characters up to the specific char.
*
* @param c the delimiter
* @return the chars read
*/
public String consumeTo(char c) {
int offset = nextIndexOf(c);
if (offset != -1) {
String consumed = cacheString(charBuf, stringCache, bufPos, offset);
bufPos += offset;
return consumed;
} else {
return consumeToEnd();
}
}
String consumeTo(String seq) {
int offset = nextIndexOf(seq);
if (offset != -1) {
String consumed = cacheString(charBuf, stringCache, bufPos, offset);
bufPos += offset;
return consumed;
} else {
return consumeToEnd();
}
}
/**
* Read characters until the first of any delimiters is found.
*
* @param chars delimiters to scan for
* @return characters read up to the matched delimiter.
*/
public String consumeToAny(final char... chars) {
bufferUp();
final int start = bufPos;
final int remaining = bufLength;
final char[] val = charBuf;
OUTER:
while (bufPos < remaining) {
for (char c : chars) {
if (val[bufPos] == c)
break OUTER;
}
bufPos++;
}
return bufPos > start ? cacheString(charBuf, stringCache, start, bufPos - start) : "";
}
String consumeToAnySorted(final char... chars) {
bufferUp();
final int start = bufPos;
final int remaining = bufLength;
final char[] val = charBuf;
while (bufPos < remaining) {
if (Arrays.binarySearch(chars, val[bufPos]) >= 0)
break;
bufPos++;
}
return bufPos > start ? cacheString(charBuf, stringCache, start, bufPos - start) : "";
}
String consumeData() {
// &, <, null
bufferUp();
final int start = bufPos;
final int remaining = bufLength;
final char[] val = charBuf;
while (bufPos < remaining) {
final char c = val[bufPos];
if (c == '&' || c == '<' || c == TokeniserState.nullChar)
break;
bufPos++;
}
return bufPos > start ? cacheString(charBuf, stringCache, start, bufPos - start) : "";
}
String consumeTagName() {
// '\t', '\n', '\r', '\f', ' ', '/', '>', nullChar
bufferUp();
final int start = bufPos;
final int remaining = bufLength;
final char[] val = charBuf;
while (bufPos < remaining) {
final char c = val[bufPos];
if (c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == ' ' || c == '/' || c == '>' || c == TokeniserState.nullChar)
break;
bufPos++;
}
return bufPos > start ? cacheString(charBuf, stringCache, start, bufPos - start) : "";
}
String consumeToEnd() {
bufferUp();
String data = cacheString(charBuf, stringCache, bufPos, bufLength - bufPos);
bufPos = bufLength;
return data;
}
String consumeLetterSequence() {
bufferUp();
int start = bufPos;
while (bufPos < bufLength) {
char c = charBuf[bufPos];
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || Character.isLetter(c))
bufPos++;
else
break;
}
return cacheString(charBuf, stringCache, start, bufPos - start);
}
String consumeLetterThenDigitSequence() {
bufferUp();
int start = bufPos;
while (bufPos < bufLength) {
char c = charBuf[bufPos];
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || Character.isLetter(c))
bufPos++;
else
break;
}
while (!isEmptyNoBufferUp()) {
char c = charBuf[bufPos];
if (c >= '0' && c <= '9')
bufPos++;
else
break;
}
return cacheString(charBuf, stringCache, start, bufPos - start);
}
String consumeHexSequence() {
bufferUp();
int start = bufPos;
while (bufPos < bufLength) {
char c = charBuf[bufPos];
if ((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'))
bufPos++;
else
break;
}
return cacheString(charBuf, stringCache, start, bufPos - start);
}
String consumeDigitSequence() {
bufferUp();
int start = bufPos;
while (bufPos < bufLength) {
char c = charBuf[bufPos];
if (c >= '0' && c <= '9')
bufPos++;
else
break;
}
return cacheString(charBuf, stringCache, start, bufPos - start);
}
boolean matches(char c) {
return !isEmpty() && charBuf[bufPos] == c;
}
boolean matches(String seq) {
bufferUp();
int scanLength = seq.length();
if (scanLength > bufLength - bufPos)
return false;
for (int offset = 0; offset < scanLength; offset++)
if (seq.charAt(offset) != charBuf[bufPos + offset])
return false;
return true;
}
boolean matchesIgnoreCase(String seq) {
bufferUp();
int scanLength = seq.length();
if (scanLength > bufLength - bufPos)
return false;
for (int offset = 0; offset < scanLength; offset++) {
char upScan = Character.toUpperCase(seq.charAt(offset));
char upTarget = Character.toUpperCase(charBuf[bufPos + offset]);
if (upScan != upTarget)
return false;
}
return true;
}
boolean matchesAny(char... seq) {
if (isEmpty())
return false;
bufferUp();
char c = charBuf[bufPos];
for (char seek : seq) {
if (seek == c)
return true;
}
return false;
}
boolean matchesAnySorted(char[] seq) {
bufferUp();
return !isEmpty() && Arrays.binarySearch(seq, charBuf[bufPos]) >= 0;
}
boolean matchesLetter() {
if (isEmpty())
return false;
char c = charBuf[bufPos];
return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || Character.isLetter(c);
}
boolean matchesDigit() {
if (isEmpty())
return false;
char c = charBuf[bufPos];
return (c >= '0' && c <= '9');
}
boolean matchConsume(String seq) {
bufferUp();
if (matches(seq)) {
bufPos += seq.length();
return true;
} else {
return false;
}
}
boolean matchConsumeIgnoreCase(String seq) {
if (matchesIgnoreCase(seq)) {
bufPos += seq.length();
return true;
} else {
return false;
}
}
boolean containsIgnoreCase(String seq) {
// used to check presence of </title>, </style>. only finds consistent case.
String loScan = seq.toLowerCase(Locale.ENGLISH);
String hiScan = seq.toUpperCase(Locale.ENGLISH);
return (nextIndexOf(loScan) > -1) || (nextIndexOf(hiScan) > -1);
}
@Override
public String toString() {
return new String(charBuf, bufPos, bufLength - bufPos);
}
/**
* Caches short strings, as a flywheel pattern, to reduce GC load. Just for this doc, to prevent leaks.
* <p/>
* Simplistic, and on hash collisions just falls back to creating a new string, vs a full HashMap with Entry list.
* That saves both having to create objects as hash keys, and running through the entry list, at the expense of
* some more duplicates.
*/
private static String cacheString(final char[] charBuf, final String[] stringCache, final int start, final int count) {
// limit (no cache):
if (count > maxStringCacheLen)
return new String(charBuf, start, count);
if (count < 1)
return "";
// calculate hash:
int hash = 0;
int offset = start;
for (int i = 0; i < count; i++) {
hash = 31 * hash + charBuf[offset++];
}
// get from cache
final int index = hash & stringCache.length - 1;
String cached = stringCache[index];
if (cached == null) { // miss, add
cached = new String(charBuf, start, count);
stringCache[index] = cached;
} else { // hashcode hit, check equality
if (rangeEquals(charBuf, start, count, cached)) { // hit
return cached;
} else { // hashcode conflict
cached = new String(charBuf, start, count);
stringCache[index] = cached; // update the cache, as recently used strings are more likely to show up again
}
}
return cached;
}
/**
* Check if the value of the provided range equals the string.
*/
static boolean rangeEquals(final char[] charBuf, final int start, int count, final String cached) {
if (count == cached.length()) {
int i = start;
int j = 0;
while (count-- != 0) {
if (charBuf[i++] != cached.charAt(j++))
return false;
}
return true;
}
return false;
}
// just used for testing
boolean rangeEquals(final int start, final int count, final String cached) {
return rangeEquals(charBuf, start, count, cached);
}
}

View File

@ -0,0 +1,41 @@
package ru.noties.markwon.html.impl.jsoup.parser;
/**
* A Parse Error records an error in the input HTML that occurs in either the tokenisation or the tree building phase.
*/
public class ParseError {
private int pos;
private String errorMsg;
ParseError(int pos, String errorMsg) {
this.pos = pos;
this.errorMsg = errorMsg;
}
ParseError(int pos, String errorFormat, Object... args) {
this.errorMsg = String.format(errorFormat, args);
this.pos = pos;
}
/**
* Retrieve the error message.
* @return the error message.
*/
public String getErrorMessage() {
return errorMsg;
}
/**
* Retrieves the offset of the error.
* @return error offset within input
*/
public int getPosition() {
return pos;
}
@Override
public String toString() {
return pos + ": " + errorMsg;
}
}

View File

@ -0,0 +1,34 @@
package ru.noties.markwon.html.impl.jsoup.parser;
import java.util.ArrayList;
/**
* A container for ParseErrors.
*
* @author Jonathan Hedley
*/
public class ParseErrorList extends ArrayList<ParseError>{
private static final int INITIAL_CAPACITY = 16;
private final int maxSize;
ParseErrorList(int initialCapacity, int maxSize) {
super(initialCapacity);
this.maxSize = maxSize;
}
boolean canAddError() {
return size() < maxSize;
}
int getMaxSize() {
return maxSize;
}
public static ParseErrorList noTracking() {
return new ParseErrorList(0, 0);
}
public static ParseErrorList tracking(int maxSize) {
return new ParseErrorList(INITIAL_CAPACITY, maxSize);
}
}

View File

@ -0,0 +1,398 @@
package ru.noties.markwon.html.impl.jsoup.parser;
import android.support.annotation.NonNull;
import ru.noties.markwon.html.impl.jsoup.helper.Validate;
import ru.noties.markwon.html.impl.jsoup.nodes.Attributes;
import static ru.noties.markwon.html.impl.jsoup.helper.Normalizer.lowerCase;
/**
* Parse tokens for the Tokeniser.
*/
public abstract class Token {
public final TokenType type;
protected Token(@NonNull TokenType tokenType) {
this.type = tokenType;
}
// String tokenType() {
// return this.getClass().getSimpleName();
// }
/**
* Reset the data represent by this token, for reuse. Prevents the need to create transfer objects for every
* piece of data, which immediately get GCed.
*/
public abstract Token reset();
static void reset(StringBuilder sb) {
if (sb != null) {
sb.delete(0, sb.length());
}
}
public static final class Doctype extends Token {
final StringBuilder name = new StringBuilder();
String pubSysKey = null;
final StringBuilder publicIdentifier = new StringBuilder();
final StringBuilder systemIdentifier = new StringBuilder();
boolean forceQuirks = false;
Doctype() {
super(TokenType.Doctype);
}
@Override
public Token reset() {
reset(name);
pubSysKey = null;
reset(publicIdentifier);
reset(systemIdentifier);
forceQuirks = false;
return this;
}
String getName() {
return name.toString();
}
String getPubSysKey() {
return pubSysKey;
}
String getPublicIdentifier() {
return publicIdentifier.toString();
}
public String getSystemIdentifier() {
return systemIdentifier.toString();
}
public boolean isForceQuirks() {
return forceQuirks;
}
}
public static abstract class Tag extends Token {
public String tagName;
public String normalName; // lc version of tag name, for case insensitive tree build
private String pendingAttributeName; // attribute names are generally caught in one hop, not accumulated
private StringBuilder pendingAttributeValue = new StringBuilder(); // but values are accumulated, from e.g. & in hrefs
private String pendingAttributeValueS; // try to get attr vals in one shot, vs Builder
private boolean hasEmptyAttributeValue = false; // distinguish boolean attribute from empty string value
private boolean hasPendingAttributeValue = false;
public boolean selfClosing = false;
public Attributes attributes; // start tags get attributes on construction. End tags get attributes on first new attribute (but only for parser convenience, not used).
protected Tag(@NonNull TokenType tokenType) {
super(tokenType);
}
@Override
public Tag reset() {
tagName = null;
normalName = null;
pendingAttributeName = null;
reset(pendingAttributeValue);
pendingAttributeValueS = null;
hasEmptyAttributeValue = false;
hasPendingAttributeValue = false;
selfClosing = false;
attributes = null;
return this;
}
final void newAttribute() {
if (attributes == null)
attributes = new Attributes();
if (pendingAttributeName != null) {
// the tokeniser has skipped whitespace control chars, but trimming could collapse to empty for other control codes, so verify here
pendingAttributeName = pendingAttributeName.trim();
if (pendingAttributeName.length() > 0) {
String value;
if (hasPendingAttributeValue)
value = pendingAttributeValue.length() > 0 ? pendingAttributeValue.toString() : pendingAttributeValueS;
else if (hasEmptyAttributeValue)
value = "";
else
value = null;
attributes.put(pendingAttributeName, value);
}
}
pendingAttributeName = null;
hasEmptyAttributeValue = false;
hasPendingAttributeValue = false;
reset(pendingAttributeValue);
pendingAttributeValueS = null;
}
final void finaliseTag() {
// finalises for emit
if (pendingAttributeName != null) {
// todo: check if attribute name exists; if so, drop and error
newAttribute();
}
}
final String name() { // preserves case, for input into Tag.valueOf (which may drop case)
Validate.isFalse(tagName == null || tagName.length() == 0);
return tagName;
}
final String normalName() { // loses case, used in tree building for working out where in tree it should go
return normalName;
}
final Tag name(String name) {
tagName = name;
normalName = lowerCase(name);
return this;
}
final boolean isSelfClosing() {
return selfClosing;
}
@SuppressWarnings({"TypeMayBeWeakened"})
final Attributes getAttributes() {
return attributes;
}
// these appenders are rarely hit in not null state-- caused by null chars.
final void appendTagName(String append) {
tagName = tagName == null ? append : tagName.concat(append);
normalName = lowerCase(tagName);
}
final void appendTagName(char append) {
appendTagName(String.valueOf(append));
}
final void appendAttributeName(String append) {
pendingAttributeName = pendingAttributeName == null ? append : pendingAttributeName.concat(append);
}
final void appendAttributeName(char append) {
appendAttributeName(String.valueOf(append));
}
final void appendAttributeValue(String append) {
ensureAttributeValue();
if (pendingAttributeValue.length() == 0) {
pendingAttributeValueS = append;
} else {
pendingAttributeValue.append(append);
}
}
final void appendAttributeValue(char append) {
ensureAttributeValue();
pendingAttributeValue.append(append);
}
final void appendAttributeValue(char[] append) {
ensureAttributeValue();
pendingAttributeValue.append(append);
}
final void appendAttributeValue(int[] appendCodepoints) {
ensureAttributeValue();
for (int codepoint : appendCodepoints) {
pendingAttributeValue.appendCodePoint(codepoint);
}
}
final void setEmptyAttributeValue() {
hasEmptyAttributeValue = true;
}
private void ensureAttributeValue() {
hasPendingAttributeValue = true;
// if on second hit, we'll need to move to the builder
if (pendingAttributeValueS != null) {
pendingAttributeValue.append(pendingAttributeValueS);
pendingAttributeValueS = null;
}
}
}
public final static class StartTag extends Tag {
public StartTag() {
super(TokenType.StartTag);
attributes = new Attributes();
}
@Override
public Tag reset() {
super.reset();
attributes = new Attributes();
// todo - would prefer these to be null, but need to check Element assertions
return this;
}
StartTag nameAttr(String name, Attributes attributes) {
this.tagName = name;
this.attributes = attributes;
normalName = lowerCase(tagName);
return this;
}
@Override
public String toString() {
if (attributes != null && attributes.size() > 0)
return "<" + name() + " " + attributes.toString() + ">";
else
return "<" + name() + ">";
}
}
public final static class EndTag extends Tag{
EndTag() {
super(TokenType.EndTag);
}
@Override
public String toString() {
return "</" + name() + ">";
}
}
public final static class Comment extends Token {
final StringBuilder data = new StringBuilder();
boolean bogus = false;
@Override
public Token reset() {
reset(data);
bogus = false;
return this;
}
Comment() {
super(TokenType.Comment);
}
String getData() {
return data.toString();
}
@Override
public String toString() {
return "<!--" + getData() + "-->";
}
}
public static class Character extends Token {
private String data;
Character() {
super(TokenType.Character);
}
@Override
public Token reset() {
data = null;
return this;
}
Character data(String data) {
this.data = data;
return this;
}
public String getData() {
return data;
}
@Override
public String toString() {
return getData();
}
}
public final static class CData extends Character {
CData(String data) {
super();
this.data(data);
}
@Override
public String toString() {
return "<![CDATA[" + getData() + "]]>";
}
}
public final static class EOF extends Token {
EOF() {
super(Token.TokenType.EOF);
}
@Override
public Token reset() {
return this;
}
}
// final boolean isDoctype() {
// return type == TokenType.Doctype;
// }
//
// final Doctype asDoctype() {
// return (Doctype) this;
// }
//
// final boolean isStartTag() {
// return type == TokenType.StartTag;
// }
//
// final StartTag asStartTag() {
// return (StartTag) this;
// }
//
// final boolean isEndTag() {
// return type == TokenType.EndTag;
// }
//
// final EndTag asEndTag() {
// return (EndTag) this;
// }
//
// final boolean isComment() {
// return type == TokenType.Comment;
// }
//
// final Comment asComment() {
// return (Comment) this;
// }
//
// final boolean isCharacter() {
// return type == TokenType.Character;
// }
//
// final boolean isCData() {
// return this instanceof CData;
// }
//
// final Character asCharacter() {
// return (Character) this;
// }
//
// final boolean isEOF() {
// return type == TokenType.EOF;
// }
public enum TokenType {
Doctype,
StartTag,
EndTag,
Comment,
Character, // note no CData - treated in builder as an extension of Character
EOF
}
}

View File

@ -0,0 +1,295 @@
package ru.noties.markwon.html.impl.jsoup.parser;
import java.util.Arrays;
import ru.noties.markwon.html.impl.jsoup.helper.Validate;
import ru.noties.markwon.html.impl.jsoup.nodes.CommonMarkEntities;
/**
* Readers the input stream into tokens.
*/
public final class Tokeniser {
static final char replacementChar = '\uFFFD'; // replaces null character
private static final char[] notCharRefCharsSorted = new char[]{'\t', '\n', '\r', '\f', ' ', '<', '&'};
// Some illegal character escapes are parsed by browsers as windows-1252 instead. See issue #1034
// https://html.spec.whatwg.org/multipage/parsing.html#numeric-character-reference-end-state
static final int win1252ExtensionsStart = 0x80;
static final int[] win1252Extensions = new int[] {
// we could build this manually, but Windows-1252 is not a standard java charset so that could break on
// some platforms - this table is verified with a test
0x20AC, 0x0081, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021,
0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x008D, 0x017D, 0x008F,
0x0090, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014,
0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x009D, 0x017E, 0x0178,
};
static {
Arrays.sort(notCharRefCharsSorted);
}
private final CharacterReader reader; // html input
private final ParseErrorList errors; // errors found while tokenising
private TokeniserState state = TokeniserState.Data; // current tokenisation state
private Token emitPending; // the token we are about to emit on next read
private boolean isEmitPending = false;
private String charsString = null; // characters pending an emit. Will fall to charsBuilder if more than one
private StringBuilder charsBuilder = new StringBuilder(1024); // buffers characters to output as one token, if more than one emit per read
StringBuilder dataBuffer = new StringBuilder(1024); // buffers data looking for </script>
Token.Tag tagPending; // tag we are building up
Token.StartTag startPending = new Token.StartTag();
Token.EndTag endPending = new Token.EndTag();
Token.Character charPending = new Token.Character();
Token.Doctype doctypePending = new Token.Doctype(); // doctype building up
Token.Comment commentPending = new Token.Comment(); // comment building up
private String lastStartTag; // the last start tag emitted, to test appropriate end tag
public Tokeniser(CharacterReader reader, ParseErrorList errors) {
this.reader = reader;
this.errors = errors;
}
public Token read() {
while (!isEmitPending)
state.read(this, reader);
// if emit is pending, a non-character token was found: return any chars in buffer, and leave token for next read:
if (charsBuilder.length() > 0) {
String str = charsBuilder.toString();
charsBuilder.delete(0, charsBuilder.length());
charsString = null;
return charPending.data(str);
} else if (charsString != null) {
Token token = charPending.data(charsString);
charsString = null;
return token;
} else {
isEmitPending = false;
return emitPending;
}
}
void emit(Token token) {
Validate.isFalse(isEmitPending, "There is an unread token pending!");
emitPending = token;
isEmitPending = true;
if (token.type == Token.TokenType.StartTag) {
Token.StartTag startTag = (Token.StartTag) token;
lastStartTag = startTag.tagName;
} else if (token.type == Token.TokenType.EndTag) {
Token.EndTag endTag = (Token.EndTag) token;
if (endTag.attributes != null)
error("Attributes incorrectly present on end tag");
}
}
void emit(final String str) {
// buffer strings up until last string token found, to emit only one token for a run of character refs etc.
// does not set isEmitPending; read checks that
if (charsString == null) {
charsString = str;
}
else {
if (charsBuilder.length() == 0) { // switching to string builder as more than one emit before read
charsBuilder.append(charsString);
}
charsBuilder.append(str);
}
}
void emit(char[] chars) {
emit(String.valueOf(chars));
}
void emit(int[] codepoints) {
emit(new String(codepoints, 0, codepoints.length));
}
void emit(char c) {
emit(String.valueOf(c));
}
TokeniserState getState() {
return state;
}
void transition(TokeniserState state) {
this.state = state;
}
void advanceTransition(TokeniserState state) {
reader.advance();
this.state = state;
}
final private int[] codepointHolder = new int[1]; // holder to not have to keep creating arrays
final private int[] multipointHolder = new int[2];
int[] consumeCharacterReference(Character additionalAllowedCharacter, boolean inAttribute) {
if (reader.isEmpty())
return null;
if (additionalAllowedCharacter != null && additionalAllowedCharacter == reader.current())
return null;
if (reader.matchesAnySorted(notCharRefCharsSorted))
return null;
final int[] codeRef = codepointHolder;
reader.mark();
if (reader.matchConsume("#")) { // numbered
boolean isHexMode = reader.matchConsumeIgnoreCase("X");
String numRef = isHexMode ? reader.consumeHexSequence() : reader.consumeDigitSequence();
if (numRef.length() == 0) { // didn't match anything
characterReferenceError("numeric reference with no numerals");
reader.rewindToMark();
return null;
}
if (!reader.matchConsume(";"))
characterReferenceError("missing semicolon"); // missing semi
int charval = -1;
try {
int base = isHexMode ? 16 : 10;
charval = Integer.valueOf(numRef, base);
} catch (NumberFormatException ignored) {
} // skip
if (charval == -1 || (charval >= 0xD800 && charval <= 0xDFFF) || charval > 0x10FFFF) {
characterReferenceError("character outside of valid range");
codeRef[0] = replacementChar;
return codeRef;
} else {
// fix illegal unicode characters to match browser behavior
if (charval >= win1252ExtensionsStart && charval < win1252ExtensionsStart + win1252Extensions.length) {
characterReferenceError("character is not a valid unicode code point");
charval = win1252Extensions[charval - win1252ExtensionsStart];
}
// todo: implement number replacement table
// todo: check for extra illegal unicode points as parse errors
codeRef[0] = charval;
return codeRef;
}
} else { // named
// get as many letters as possible, and look for matching entities.
String nameRef = reader.consumeLetterThenDigitSequence();
boolean looksLegit = reader.matches(';');
// found if a base named entity without a ;, or an extended entity with the ;.
boolean found = (CommonMarkEntities.isNamedEntity(nameRef) && looksLegit);
if (!found) {
reader.rewindToMark();
if (looksLegit) // named with semicolon
characterReferenceError(String.format("invalid named referenece '%s'", nameRef));
return null;
}
if (inAttribute && (reader.matchesLetter() || reader.matchesDigit() || reader.matchesAny('=', '-', '_'))) {
// don't want that to match
reader.rewindToMark();
return null;
}
if (!reader.matchConsume(";"))
characterReferenceError("missing semicolon"); // missing semi
int numChars = CommonMarkEntities.codepointsForName(nameRef, multipointHolder);
if (numChars == 1) {
codeRef[0] = multipointHolder[0];
return codeRef;
} else if (numChars ==2) {
return multipointHolder;
} else {
Validate.fail("Unexpected characters returned for " + nameRef);
return multipointHolder;
}
}
}
Token.Tag createTagPending(boolean start) {
tagPending = start ? startPending.reset() : endPending.reset();
return tagPending;
}
void emitTagPending() {
tagPending.finaliseTag();
emit(tagPending);
}
void createCommentPending() {
commentPending.reset();
}
void emitCommentPending() {
emit(commentPending);
}
void createDoctypePending() {
doctypePending.reset();
}
void emitDoctypePending() {
emit(doctypePending);
}
void createTempBuffer() {
Token.reset(dataBuffer);
}
boolean isAppropriateEndTagToken() {
return lastStartTag != null && tagPending.name().equalsIgnoreCase(lastStartTag);
}
String appropriateEndTagName() {
return lastStartTag; // could be null
}
void error(TokeniserState state) {
if (errors.canAddError())
errors.add(new ParseError(reader.pos(), "Unexpected character '%s' in input state [%s]", reader.current(), state));
}
void eofError(TokeniserState state) {
if (errors.canAddError())
errors.add(new ParseError(reader.pos(), "Unexpectedly reached end of file (EOF) in input state [%s]", state));
}
private void characterReferenceError(String message) {
if (errors.canAddError())
errors.add(new ParseError(reader.pos(), "Invalid character reference: %s", message));
}
void error(String errorMsg) {
if (errors.canAddError())
errors.add(new ParseError(reader.pos(), errorMsg));
}
boolean currentNodeInHtmlNS() {
// todo: implement namespaces correctly
return true;
// Element currentNode = currentNode();
// return currentNode != null && currentNode.namespace().equals("HTML");
}
// /**
// * Utility method to consume reader and unescape entities found within.
// * @param inAttribute if the text to be unescaped is in an attribute
// * @return unescaped string from reader
// */
// String unescapeEntities(boolean inAttribute) {
// StringBuilder builder = StringUtil.stringBuilder();
// while (!reader.isEmpty()) {
// builder.append(reader.consumeTo('&'));
// if (reader.matches('&')) {
// reader.consume();
// int[] c = consumeCharacterReference(null, inAttribute);
// if (c == null || c.length==0)
// builder.append('&');
// else {
// builder.appendCodePoint(c[0]);
// if (c.length == 2)
// builder.appendCodePoint(c[1]);
// }
//
// }
// }
// return builder.toString();
// }
}

View File

@ -0,0 +1,47 @@
package ru.noties.markwon.html.impl;
import org.junit.Before;
import org.junit.Test;
import java.util.Collections;
import ru.noties.markwon.html.api.HtmlTag;
import ru.noties.markwon.html.impl.HtmlTagImpl.InlineImpl;
import static org.junit.Assert.assertEquals;
public class HtmlEmptyTagReplacementTest {
private HtmlEmptyTagReplacement replacement;
@Before
public void before() {
replacement = HtmlEmptyTagReplacement.create();
}
@Test
public void imageReplacementNoAlt() {
final HtmlTag.Inline img = new InlineImpl("img", -1, Collections.<String, String>emptyMap());
assertEquals("\uFFFC", replacement.replace(img));
}
@Test
public void imageReplacementAlt() {
final HtmlTag.Inline img = new InlineImpl(
"img",
-1,
Collections.singletonMap("alt", "alternative27")
);
assertEquals("alternative27", replacement.replace(img));
}
@Test
public void brAddsNewLine() {
final HtmlTag.Inline br = new InlineImpl(
"br",
-1,
Collections.<String, String>emptyMap()
);
assertEquals("\n", replacement.replace(br));
}
}

View File

@ -0,0 +1,885 @@
package ru.noties.markwon.html.impl;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import ru.noties.markwon.html.api.HtmlTag;
import ru.noties.markwon.html.api.MarkwonHtmlParser;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
@RunWith(RobolectricTestRunner.class)
@Config(manifest = Config.NONE)
public class MarkwonHtmlParserImplTest {
@Test
public void inlineTags() {
// all inline tags are correctly parsed
// a simple replacement that will return tag name as replacement (for this test purposes)
final MarkwonHtmlParserImpl impl = MarkwonHtmlParserImpl.create(new HtmlEmptyTagReplacement() {
@Nullable
@Override
public String replace(@NonNull HtmlTag tag) {
return tag.name();
}
});
// all inline tags are parsed as ones
final List<String> tags = new ArrayList<>(MarkwonHtmlParserImpl.INLINE_TAGS);
final StringBuilder html = new StringBuilder();
for (String tag : tags) {
html.append('<')
.append(tag)
.append('>')
.append(tag)
.append("</")
.append(tag)
.append('>');
}
final StringBuilder output = new StringBuilder();
impl.processFragment(output, html.toString());
final CaptureInlineTagsAction action = new CaptureInlineTagsAction();
impl.flushInlineTags(output.length(), action);
assertTrue(action.called);
final List<HtmlTag.Inline> inlines = action.tags;
if (tags.size() != inlines.size()) {
final Set<String> missing = new HashSet<>(tags);
for (HtmlTag.Inline inline : inlines) {
missing.remove(inline.name());
}
assertTrue("Missing inline tags: " + missing, false);
}
final Set<String> set = new HashSet<>(tags);
for (HtmlTag.Inline inline : inlines) {
assertTrue(set.remove(inline.name()));
assertEquals(inline.name(), output.substring(inline.start(), inline.end()));
}
assertEquals(0, set.size());
}
@Test
public void inlineVoidTags() {
// all inline void tags are correctly parsed
final List<String> tags = Arrays.asList(
"br",
"img", "input"
);
final MarkwonHtmlParserImpl impl = MarkwonHtmlParserImpl.create(new HtmlEmptyTagReplacement() {
@Nullable
@Override
public String replace(@NonNull HtmlTag tag) {
return null;
}
});
final StringBuilder html = new StringBuilder();
for (String tag : tags) {
html.append('<')
.append(tag)
.append('>');
}
final StringBuilder output = new StringBuilder();
impl.processFragment(output, html.toString());
assertEquals(0, output.length());
final CaptureInlineTagsAction action = new CaptureInlineTagsAction();
impl.flushInlineTags(output.length(), action);
assertTrue(action.called);
final List<HtmlTag.Inline> inlines = action.tags;
assertEquals(inlines.toString(), tags.size(), inlines.size());
final Set<String> set = new HashSet<>(tags);
for (HtmlTag.Inline inline : inlines) {
assertEquals(inline.name(), inline.start(), inline.end());
assertTrue(inline.name(), inline.isEmpty());
assertTrue(set.remove(inline.name()));
}
assertEquals(set.toString(), 0, set.size());
}
@Test
public void blockVoidTags() {
final MarkwonHtmlParserImpl impl = MarkwonHtmlParserImpl.create(new HtmlEmptyTagReplacement() {
@Nullable
@Override
public String replace(@NonNull HtmlTag tag) {
return null;
}
});
final List<String> tags = Arrays.asList(
"area",
"base",
"col",
"embed",
"hr",
"keygen",
"link",
"meta",
"param",
"source",
"track",
"wbr"
);
final StringBuilder html = new StringBuilder();
for (String tag : tags) {
html.append('<')
.append(tag)
.append('>');
}
final StringBuilder output = new StringBuilder();
impl.processFragment(output, html.toString());
assertEquals(0, output.length());
final CaptureBlockTagsAction action = new CaptureBlockTagsAction();
impl.flushBlockTags(output.length(), action);
assertTrue(action.called);
final List<HtmlTag.Block> blocks = action.tags;
assertEquals(blocks.toString(), tags.size(), blocks.size());
final Set<String> set = new HashSet<>(tags);
for (HtmlTag.Block block : blocks) {
assertEquals(block.name(), block.start(), block.end());
assertTrue(block.name(), block.isEmpty());
assertTrue(set.remove(block.name()));
}
assertEquals(set.toString(), 0, set.size());
}
@Test
public void selfClosingTags() {
// self-closing tags (grammatically) must be replaced (no checks for real html)
final List<String> tags = Arrays.asList(
"one",
"two-two",
"three-three-three",
"four-four-four-four",
"FiveFiveFiveFiveFive"
);
final MarkwonHtmlParserImpl impl = MarkwonHtmlParserImpl.create(new HtmlEmptyTagReplacement() {
@Nullable
@Override
public String replace(@NonNull HtmlTag tag) {
return null;
}
});
final StringBuilder html = new StringBuilder();
for (String tag : tags) {
html.append('<')
.append(tag)
.append(" />");
}
final StringBuilder output = new StringBuilder();
impl.processFragment(output, html.toString());
assertEquals(output.toString(), 0, output.length());
final CaptureBlockTagsAction action = new CaptureBlockTagsAction();
impl.flushBlockTags(output.length(), action);
assertTrue(action.called);
final List<HtmlTag.Block> blocks = action.tags;
assertEquals(blocks.toString(), tags.size(), blocks.size());
// tag names must be lower cased
final Set<String> set = new HashSet<>(tags.size());
for (String tag : tags) {
set.add(tag.toLowerCase());
}
for (HtmlTag.Block block : blocks) {
assertTrue(block.name(), block.isEmpty());
assertTrue(set.remove(block.name()));
}
assertEquals(set.toString(), 0, set.size());
}
@Test
public void blockTags() {
// the tags that will require a new line before them
final List<String> tags = Arrays.asList(
"address", "article", "aside",
"blockquote",
"canvas",
"dd", "div", "dl", "dt",
"fieldset", "figcaption", "figure", "footer", "form",
"h1", "h2", "h3", "h4", "h5", "h6", "header", "hgroup", "hr",
"li",
"main",
"nav", "noscript",
"ol", "output",
"p", "pre",
"section",
"table", "tfoot",
"ul",
"video"
);
final MarkwonHtmlParserImpl impl = MarkwonHtmlParserImpl.create(new HtmlEmptyTagReplacement() {
@Override
public String replace(@NonNull HtmlTag tag) {
return tag.name();
}
});
final StringBuilder html = new StringBuilder();
for (String tag : tags) {
html.append('<')
.append(tag)
.append('>')
.append(tag)
.append("</")
.append(tag)
.append('>');
}
final StringBuilder output = new StringBuilder();
impl.processFragment(output, html.toString());
final CaptureBlockTagsAction action = new CaptureBlockTagsAction();
impl.flushBlockTags(output.length(), action);
assertTrue(action.called);
final List<HtmlTag.Block> blocks = action.tags;
assertEquals(blocks.toString(), tags.size(), blocks.size());
final Set<String> set = new HashSet<>(tags);
boolean first = true;
for (HtmlTag.Block block : blocks) {
assertEquals(block.name(), block.name(), output.substring(block.start(), block.end()));
if (first) {
first = false;
} else {
assertEquals('\n', output.charAt(block.start() - 1));
}
assertTrue(set.remove(block.name()));
}
assertEquals(set.toString(), 0, set.size());
}
@Test
public void multipleFragmentsContinuation() {
final MarkwonHtmlParserImpl impl = MarkwonHtmlParserImpl.create(new HtmlEmptyTagReplacement());
final StringBuilder output = new StringBuilder();
impl.processFragment(output, "<i>");
output.append("italic ");
impl.processFragment(output, "</i>");
final CaptureInlineTagsAction action = new CaptureInlineTagsAction();
impl.flushInlineTags(output.length(), action);
assertTrue(action.called);
final List<HtmlTag.Inline> inlines = action.tags;
assertEquals(inlines.toString(), 1, inlines.size());
final HtmlTag.Inline inline = inlines.get(0);
assertEquals("i", inline.name());
assertEquals(0, inline.start());
assertEquals(output.length(), inline.end());
assertEquals("italic ", output.toString());
}
@Test
public void paragraphCannotContainAnythingButInlines() {
final MarkwonHtmlParserImpl impl = MarkwonHtmlParserImpl.create();
final StringBuilder output = new StringBuilder();
impl.processFragment(output, "<p><i>italic <b>bold italic <div>in-div</div>");
final CaptureInlineTagsAction inlineTagsAction = new CaptureInlineTagsAction();
final CaptureBlockTagsAction blockTagsAction = new CaptureBlockTagsAction();
impl.flushInlineTags(output.length(), inlineTagsAction);
impl.flushBlockTags(output.length(), blockTagsAction);
assertTrue(inlineTagsAction.called);
assertTrue(blockTagsAction.called);
final List<HtmlTag.Inline> inlines = inlineTagsAction.tags;
final List<HtmlTag.Block> blocks = blockTagsAction.tags;
assertEquals(2, inlines.size());
assertEquals(2, blocks.size());
// inlines will be closed at the end of the document
// P will be closed right before <div>
with(inlines.get(0), new Action<HtmlTag.Inline>() {
@Override
public void apply(@NonNull HtmlTag.Inline inline) {
assertEquals("i", inline.name());
assertEquals(0, inline.start());
assertEquals(output.length(), inline.end());
}
});
with(inlines.get(1), new Action<HtmlTag.Inline>() {
@Override
public void apply(@NonNull HtmlTag.Inline inline) {
assertEquals("b", inline.name());
assertEquals("italic ".length(), inline.start());
assertEquals(output.length(), inline.end());
}
});
with(blocks.get(0), new Action<HtmlTag.Block>() {
@Override
public void apply(@NonNull HtmlTag.Block block) {
assertEquals("p", block.name());
assertEquals(0, block.start());
assertEquals(output.indexOf("in-div") - 1, block.end());
}
});
with(blocks.get(1), new Action<HtmlTag.Block>() {
@Override
public void apply(@NonNull HtmlTag.Block block) {
assertEquals("div", block.name());
assertEquals(output.indexOf("in-div"), block.start());
assertEquals(output.length(), block.end());
}
});
}
@Test
public void blockCloseClosesChildren() {
final MarkwonHtmlParserImpl impl = MarkwonHtmlParserImpl.create();
final StringBuilder output = new StringBuilder();
final String html = "<div-1>1<div-2>2<div-3>hello!</div-1>";
impl.processFragment(output, html);
assertEquals(output.toString(), "12hello!", output.toString());
final CaptureBlockTagsAction action = new CaptureBlockTagsAction();
impl.flushBlockTags(output.length(), action);
assertTrue(action.called);
assertEquals(1, action.tags.size());
with(action.tags.get(0), new Action<HtmlTag.Block>() {
@Override
public void apply(@NonNull HtmlTag.Block block) {
final int end = output.length();
assertEquals("div-1", block.name());
assertEquals(0, block.start());
assertEquals(end, block.end());
assertEquals(1, block.children().size());
with(block.children().get(0), new Action<HtmlTag.Block>() {
@Override
public void apply(@NonNull HtmlTag.Block block) {
assertEquals("div-2", block.name());
assertEquals(1, block.start());
assertEquals(end, block.end());
assertEquals(1, block.children().size());
with(block.children().get(0), new Action<HtmlTag.Block>() {
@Override
public void apply(@NonNull HtmlTag.Block block) {
assertEquals("div-3", block.name());
assertEquals(2, block.start());
assertEquals(end, block.end());
assertEquals(0, block.children().size());
}
});
}
});
}
});
}
@Test
public void allTagsAreLowerCase() {
final MarkwonHtmlParserImpl impl = MarkwonHtmlParserImpl.create();
final StringBuilder output = new StringBuilder();
impl.processFragment(output, "<DiV><I>italic <eM>emphasis</Em> italic</i></dIv>");
final CaptureInlineTagsAction inlineTagsAction = new CaptureInlineTagsAction();
final CaptureBlockTagsAction blockTagsAction = new CaptureBlockTagsAction();
impl.flushInlineTags(output.length(), inlineTagsAction);
impl.flushBlockTags(output.length(), blockTagsAction);
assertTrue(inlineTagsAction.called);
assertTrue(blockTagsAction.called);
with(inlineTagsAction.tags, new Action<List<HtmlTag.Inline>>() {
@Override
public void apply(@NonNull List<HtmlTag.Inline> inlines) {
assertEquals(2, inlines.size());
with(inlines.get(0), new Action<HtmlTag.Inline>() {
@Override
public void apply(@NonNull HtmlTag.Inline inline) {
assertEquals("i", inline.name());
assertEquals(0, inline.start());
assertEquals(output.length(), inline.end());
}
});
with(inlines.get(1), new Action<HtmlTag.Inline>() {
@Override
public void apply(@NonNull HtmlTag.Inline inline) {
assertEquals("em", inline.name());
final int start = "italic ".length();
assertEquals(start, inline.start());
assertEquals(start + ("emphasis".length()), inline.end());
}
});
}
});
assertEquals(1, blockTagsAction.tags.size());
with(blockTagsAction.tags.get(0), new Action<HtmlTag.Block>() {
@Override
public void apply(@NonNull HtmlTag.Block block) {
assertEquals("div", block.name());
assertEquals(0, block.start());
assertEquals(output.length(), block.end());
}
});
}
@Test
public void previousListItemClosed() {
final MarkwonHtmlParserImpl impl = MarkwonHtmlParserImpl.create();
final StringBuilder output = new StringBuilder();
final String html = "<ul><li>UL-First<li>UL-Second<ol><li>OL-First<li>OL-Second</ol><li>UL-Third";
impl.processFragment(output, html);
final CaptureBlockTagsAction action = new CaptureBlockTagsAction();
impl.flushBlockTags(output.length(), action);
assertTrue(action.called);
assertEquals(1, action.tags.size());
with(action.tags.get(0), new Action<HtmlTag.Block>() {
@Override
public void apply(@NonNull HtmlTag.Block block) {
assertEquals("ul", block.name());
assertEquals(3, block.children().size());
with(block.children().get(0), new Action<HtmlTag.Block>() {
@Override
public void apply(@NonNull HtmlTag.Block block) {
assertEquals("li", block.name());
assertEquals("UL-First", output.substring(block.start(), block.end()));
assertEquals(0, block.children().size());
}
});
with(block.children().get(1), new Action<HtmlTag.Block>() {
@Override
public void apply(@NonNull HtmlTag.Block block) {
assertEquals("li", block.name());
// this block will contain nested block text also
assertEquals("UL-Second\nOL-First\nOL-Second", output.substring(block.start(), block.end()));
assertEquals(1, block.children().size());
with(block.children().get(0), new Action<HtmlTag.Block>() {
@Override
public void apply(@NonNull HtmlTag.Block block) {
assertEquals("ol", block.name());
assertEquals(2, block.children().size());
with(block.children().get(0), new Action<HtmlTag.Block>() {
@Override
public void apply(@NonNull HtmlTag.Block block) {
assertEquals("li", block.name());
assertEquals("OL-First", output.substring(block.start(), block.end()));
assertEquals(0, block.children().size());
}
});
with(block.children().get(1), new Action<HtmlTag.Block>() {
@Override
public void apply(@NonNull HtmlTag.Block block) {
assertEquals("li", block.name());
assertEquals("OL-Second", output.substring(block.start(), block.end()));
assertEquals(0, block.children().size());
}
});
}
});
}
});
with(block.children().get(2), new Action<HtmlTag.Block>() {
@Override
public void apply(@NonNull HtmlTag.Block block) {
assertEquals("li", block.name());
assertEquals("UL-Third", output.substring(block.start(), block.end()));
assertEquals(0, block.children().size());
}
});
}
});
}
@Test
public void attributes() {
final MarkwonHtmlParserImpl impl = MarkwonHtmlParserImpl.create();
final StringBuilder output = new StringBuilder();
impl.processFragment(output, "<my-tag " +
"name=no-name " +
":click='doSomething' " +
"@focus=\"focus\" " +
"@blur.native=\"blur\" " +
"android:id=\"@id/id\">my-content</my-tag>");
final CaptureBlockTagsAction action = new CaptureBlockTagsAction();
impl.flushBlockTags(output.length(), action);
assertTrue(action.called);
assertEquals(1, action.tags.size());
with(action.tags.get(0), new Action<HtmlTag.Block>() {
@Override
public void apply(@NonNull HtmlTag.Block block) {
assertEquals("my-tag", block.name());
with(block.attributes(), new Action<Map<String, String>>() {
@Override
public void apply(@NonNull Map<String, String> attributes) {
assertEquals(5, attributes.size());
assertEquals("no-name", attributes.get("name"));
assertEquals("doSomething", attributes.get(":click"));
assertEquals("focus", attributes.get("@focus"));
assertEquals("blur", attributes.get("@blur.native"));
assertEquals("@id/id", attributes.get("android:id"));
}
});
}
});
}
@Test
public void flushCloseTagsIfRequested() {
final MarkwonHtmlParserImpl impl = MarkwonHtmlParserImpl.create();
final StringBuilder output = new StringBuilder();
impl.processFragment(output, "<div><i><b><em><strong>divibemstrong");
final int end = output.length();
final CaptureInlineTagsAction inlineTagsAction = new CaptureInlineTagsAction();
final CaptureBlockTagsAction blockTagsAction = new CaptureBlockTagsAction();
impl.flushInlineTags(end, inlineTagsAction);
impl.flushBlockTags(end, blockTagsAction);
assertTrue(inlineTagsAction.called);
assertTrue(blockTagsAction.called);
with(inlineTagsAction.tags, new Action<List<HtmlTag.Inline>>() {
@Override
public void apply(@NonNull List<HtmlTag.Inline> inlines) {
assertEquals(4, inlines.size());
for (HtmlTag.Inline inline : inlines) {
assertTrue(inline.isClosed());
assertEquals(end, inline.end());
}
}
});
assertEquals(1, blockTagsAction.tags.size());
with(blockTagsAction.tags.get(0), new Action<HtmlTag.Block>() {
@Override
public void apply(@NonNull HtmlTag.Block block) {
assertTrue(block.isClosed());
assertEquals(end, block.end());
}
});
}
@Test
public void flushDoesNotCloseTagsIfNoEndRequested() {
final MarkwonHtmlParserImpl impl = MarkwonHtmlParserImpl.create();
final StringBuilder output = new StringBuilder();
impl.processFragment(output, "<div><i><b><em><strong>divibemstrong");
final CaptureInlineTagsAction inlineTagsAction = new CaptureInlineTagsAction();
final CaptureBlockTagsAction blockTagsAction = new CaptureBlockTagsAction();
impl.flushInlineTags(HtmlTag.NO_END, inlineTagsAction);
impl.flushBlockTags(HtmlTag.NO_END, blockTagsAction);
assertTrue(inlineTagsAction.called);
assertTrue(blockTagsAction.called);
with(inlineTagsAction.tags, new Action<List<HtmlTag.Inline>>() {
@Override
public void apply(@NonNull List<HtmlTag.Inline> inlines) {
assertEquals(4, inlines.size());
for (HtmlTag.Inline inline : inlines) {
assertFalse(inline.isClosed());
assertEquals(HtmlTag.NO_END, inline.end());
}
}
});
assertEquals(1, blockTagsAction.tags.size());
with(blockTagsAction.tags.get(0), new Action<HtmlTag.Block>() {
@Override
public void apply(@NonNull HtmlTag.Block block) {
assertFalse(block.isClosed());
assertEquals(HtmlTag.NO_END, block.end());
}
});
}
@Test
public void flushClearsInternalState() {
final MarkwonHtmlParserImpl impl = MarkwonHtmlParserImpl.create();
final StringBuilder output = new StringBuilder();
impl.processFragment(output, "<p><i>italic <b>bold italic</b></i></p><p>paragraph</p><div>and a div</div>");
final CaptureInlineTagsAction inlineTagsAction = new CaptureInlineTagsAction();
final CaptureBlockTagsAction blockTagsAction = new CaptureBlockTagsAction();
impl.flushInlineTags(output.length(), inlineTagsAction);
impl.flushBlockTags(output.length(), blockTagsAction);
assertTrue(inlineTagsAction.called);
assertTrue(blockTagsAction.called);
assertEquals(2, inlineTagsAction.tags.size());
assertEquals(3, blockTagsAction.tags.size());
final CaptureInlineTagsAction captureInlineTagsAction = new CaptureInlineTagsAction();
final CaptureBlockTagsAction captureBlockTagsAction = new CaptureBlockTagsAction();
impl.flushInlineTags(output.length(), captureInlineTagsAction);
impl.flushBlockTags(output.length(), captureBlockTagsAction);
assertTrue(captureInlineTagsAction.called);
assertTrue(captureBlockTagsAction.called);
assertEquals(0, captureInlineTagsAction.tags.size());
assertEquals(0, captureBlockTagsAction.tags.size());
}
@Test
public void resetClearsBothInlinesAndBlocks() {
final MarkwonHtmlParserImpl impl = MarkwonHtmlParserImpl.create();
final StringBuilder output = new StringBuilder();
impl.processFragment(output, "<p>paragraph <i>italic</i></p><div>div</div>");
impl.reset();
final CaptureInlineTagsAction inlineTagsAction = new CaptureInlineTagsAction();
final CaptureBlockTagsAction blockTagsAction = new CaptureBlockTagsAction();
impl.flushInlineTags(output.length(), inlineTagsAction);
impl.flushBlockTags(output.length(), blockTagsAction);
assertTrue(inlineTagsAction.called);
assertTrue(blockTagsAction.called);
assertEquals(0, inlineTagsAction.tags.size());
assertEquals(0, blockTagsAction.tags.size());
}
@Test
public void blockTagNewLine() {
// we should make sure that a block tag will have a new line for it's
// content (white spaces before should be ignored)
final MarkwonHtmlParserImpl impl = MarkwonHtmlParserImpl.create();
final String html = "<ul>" +
" <li>ul-first" +
" <li>ul-second" +
" <ol>" +
" <li>ol-first" +
" <li>ol-second" +
" </ol>" +
" <li>ul-third" +
"</ul>";
final StringBuilder output = new StringBuilder();
impl.processFragment(output, html);
final String[] split = output.toString().split("\n");
assertEquals(Arrays.toString(split), 5, split.length);
}
@Test
public void attributesAreLowerCase() {
final MarkwonHtmlParserImpl impl = MarkwonHtmlParserImpl.create();
final StringBuilder output = new StringBuilder();
impl.processFragment(output, "<i CLASS=\"my-class\" dIsAbLeD @HeLLo=\"there\">");
final CaptureInlineTagsAction action = new CaptureInlineTagsAction();
impl.flushInlineTags(output.length(), action);
assertTrue(action.called);
assertEquals(1, action.tags.size());
with(action.tags.get(0), new Action<HtmlTag.Inline>() {
@Override
public void apply(@NonNull HtmlTag.Inline inline) {
assertEquals("i", inline.name());
with(inline.attributes(), new Action<Map<String, String>>() {
@Override
public void apply(@NonNull Map<String, String> map) {
assertEquals(3, map.size());
assertEquals("my-class", map.get("class"));
assertEquals("", map.get("disabled"));
assertEquals("there", map.get("@hello"));
}
});
}
});
}
@Test
public void newLineAfterBlockTag() {
final MarkwonHtmlParserImpl impl = MarkwonHtmlParserImpl.create();
final StringBuilder output = new StringBuilder();
final String[] fragments = {
"<h1>head #1</h1>just text",
"<h2>head #2</h2><span>in span tag</span>",
"<h3>head #3</h3><custom-tag>in custom-tag</custom-tag>"
};
for (String fragment: fragments) {
impl.processFragment(output, fragment);
}
final String expected = "" +
"head #1\njust text\n" +
"head #2\nin span tag\n" +
"head #3\nin custom-tag";
assertEquals(expected, output.toString());
}
private static class CaptureTagsAction<T> implements MarkwonHtmlParser.FlushAction<T> {
boolean called;
List<T> tags;
@Override
public void apply(@NonNull List<T> tags) {
this.called = true;
this.tags = new ArrayList<>(tags);
}
}
private static class CaptureInlineTagsAction extends CaptureTagsAction<HtmlTag.Inline> {
}
private static class CaptureBlockTagsAction extends CaptureTagsAction<HtmlTag.Block> {
}
private interface Action<T> {
void apply(@NonNull T t);
}
private static <T> void with(@NonNull T t, @NonNull Action<T> action) {
action.apply(t);
}
}

View File

@ -0,0 +1,43 @@
package ru.noties.markwon.html.impl;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import static org.junit.Assert.assertEquals;
@RunWith(RobolectricTestRunner.class)
@Config(manifest = Config.NONE)
public class TrimmingAppenderTest {
private TrimmingAppender.Impl impl;
@Before
public void before() {
impl = new TrimmingAppender.Impl();
}
@Test
public void singlePart() {
final String input = " html body \n\ndiv hey ";
final StringBuilder builder = new StringBuilder();
impl.append(builder, input);
assertEquals("html body div hey ", builder.toString());
}
@Test
public void multiParts() {
final String[] inputs = {
"\n\n\n\n\nhtml\t body\n\ndiv ",
" span and go"
};
final StringBuilder builder = new StringBuilder();
for (String input : inputs) {
impl.append(builder, input);
}
assertEquals("html body div span and go", builder.toString());
}
}

View File

@ -0,0 +1,22 @@
package ru.noties.markwon.html.impl.jsoup.nodes;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
@RunWith(RobolectricTestRunner.class)
@Config(manifest = Config.NONE)
public class CommonMarkEntitiesTest {
@Test
public void can_access_field() {
assertTrue("&", CommonMarkEntities.isNamedEntity("amp"));
final int[] codepoints = new int[1];
CommonMarkEntities.codepointsForName("amp", codepoints);
assertEquals('&', codepoints[0]);
}
}

View File

@ -0,0 +1,37 @@
apply plugin: 'com.android.library'
android {
compileSdkVersion config['compile-sdk']
buildToolsVersion config['build-tools']
defaultConfig {
minSdkVersion config['min-sdk']
targetSdkVersion config['target-sdk']
versionCode 1
versionName version
}
lintOptions {
// okio....
disable 'InvalidPackage'
}
}
dependencies {
api project(':markwon')
deps.with {
api it['android-svg']
api it['android-gif']
api it['okhttp']
}
deps['test'].with {
testImplementation it['junit']
testImplementation it['robolectric']
}
}
registerArtifact(this)

View File

@ -8,26 +8,18 @@ import android.os.Looper;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import okhttp3.Call;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
import ru.noties.markwon.spans.AsyncDrawable;
public class AsyncDrawableLoader implements AsyncDrawable.Loader {
@ -42,25 +34,19 @@ public class AsyncDrawableLoader implements AsyncDrawable.Loader {
return new Builder();
}
private static final String HEADER_CONTENT_TYPE = "Content-Type";
private static final String FILE_ANDROID_ASSETS = "android_asset";
private final OkHttpClient client;
private final Resources resources;
private final ExecutorService executorService;
private final Handler mainThread;
private final Drawable errorDrawable;
private final Map<String, SchemeHandler> schemeHandlers;
private final List<MediaDecoder> mediaDecoders;
private final Map<String, Future<?>> requests;
AsyncDrawableLoader(Builder builder) {
this.client = builder.client;
this.resources = builder.resources;
this.executorService = builder.executorService;
this.mainThread = new Handler(Looper.getMainLooper());
this.errorDrawable = builder.errorDrawable;
this.schemeHandlers = builder.schemeHandlers;
this.mediaDecoders = builder.mediaDecoders;
this.requests = new HashMap<>(3);
}
@ -80,56 +66,64 @@ public class AsyncDrawableLoader implements AsyncDrawable.Loader {
request.cancel(true);
}
final List<Call> calls = client.dispatcher().queuedCalls();
if (calls != null) {
for (Call call : calls) {
if (!call.isCanceled()) {
if (destination.equals(call.request().tag())) {
call.cancel();
}
}
}
for (SchemeHandler schemeHandler : schemeHandlers.values()) {
schemeHandler.cancel(destination);
}
}
private Future<?> execute(@NonNull final String destination, @NonNull AsyncDrawable drawable) {
final WeakReference<AsyncDrawable> reference = new WeakReference<AsyncDrawable>(drawable);
// todo, if not a link -> show placeholder
// todo: should we cancel pending request for the same destination?
// we _could_ but there is possibility that one resource is request in multiple places
// todo: error handing (simply applying errorDrawable is not a good solution
// as reason for an error is unclear (no scheme handler, no input data, error decoding, etc)
// todo: more efficient ImageMediaDecoder... BitmapFactory.decodeStream is a bit not optimal
// for big images for sure. We _could_ introduce internal Drawable that will check for
// image bounds (but we will need to cache inputStream in order to inspect and optimize
// input image...)
return executorService.submit(new Runnable() {
@Override
public void run() {
final Item item;
final boolean isFromFile;
final ImageItem item;
final Uri uri = Uri.parse(destination);
if ("file".equals(uri.getScheme())) {
item = fromFile(uri);
isFromFile = true;
final SchemeHandler schemeHandler = schemeHandlers.get(uri.getScheme());
if (schemeHandler != null) {
item = schemeHandler.handle(destination, uri);
} else {
item = fromNetwork(destination);
isFromFile = false;
item = null;
}
final InputStream inputStream = item != null
? item.inputStream()
: null;
Drawable result = null;
if (item != null
&& item.inputStream != null) {
if (inputStream != null) {
try {
final MediaDecoder mediaDecoder = isFromFile
? mediaDecoderFromFile(item.fileName)
: mediaDecoderFromContentType(item.contentType);
final String fileName = item.fileName();
final MediaDecoder mediaDecoder = fileName != null
? mediaDecoderFromFile(fileName)
: mediaDecoderFromContentType(item.contentType());
if (mediaDecoder != null) {
result = mediaDecoder.decode(item.inputStream);
result = mediaDecoder.decode(inputStream);
}
} finally {
try {
item.inputStream.close();
inputStream.close();
} catch (IOException e) {
// no op
// ignored
}
}
}
@ -157,88 +151,6 @@ public class AsyncDrawableLoader implements AsyncDrawable.Loader {
});
}
@Nullable
private Item fromFile(@NonNull Uri uri) {
final List<String> segments = uri.getPathSegments();
if (segments == null
|| segments.size() == 0) {
// pointing to file & having no path segments is no use
return null;
}
final Item out;
final InputStream inputStream;
final boolean assets = FILE_ANDROID_ASSETS.equals(segments.get(0));
final String fileName = uri.getLastPathSegment();
if (assets) {
final StringBuilder path = new StringBuilder();
for (int i = 1, size = segments.size(); i < size; i++) {
if (i != 1) {
path.append('/');
}
path.append(segments.get(i));
}
// load assets
InputStream inner = null;
try {
inner = resources.getAssets().open(path.toString());
} catch (IOException e) {
e.printStackTrace();
}
inputStream = inner;
} else {
InputStream inner = null;
try {
inner = new BufferedInputStream(new FileInputStream(new File(uri.getPath())));
} catch (FileNotFoundException e) {
e.printStackTrace();
}
inputStream = inner;
}
if (inputStream != null) {
out = new Item(fileName, null, inputStream);
} else {
out = null;
}
return out;
}
@Nullable
private Item fromNetwork(@NonNull String destination) {
Item out = null;
final Request request = new Request.Builder()
.url(destination)
.tag(destination)
.build();
Response response = null;
try {
response = client.newCall(request).execute();
} catch (IOException e) {
e.printStackTrace();
}
if (response != null) {
final ResponseBody body = response.body();
if (body != null) {
final InputStream inputStream = body.byteStream();
if (inputStream != null) {
final String contentType = response.header(HEADER_CONTENT_TYPE);
out = new Item(null, contentType, inputStream);
}
}
}
return out;
}
@Nullable
private MediaDecoder mediaDecoderFromFile(@NonNull String fileName) {
@ -269,18 +181,38 @@ public class AsyncDrawableLoader implements AsyncDrawable.Loader {
return out;
}
// todo: as now we have different layers of abstraction (for scheme handling and media decoding)
// we no longer should add dependencies implicitly, it would be way better to allow adding
// multiple artifacts (file, data, network, svg, gif)... at least, maybe we can extract API
// for this module (without implementations), but keep _all-in_ (fat) artifact with all of these.
public static class Builder {
/**
* @deprecated 2.0.0 add {@link NetworkSchemeHandler} directly
*/
@Deprecated
private OkHttpClient client;
/**
* @deprecated 2.0.0 construct {@link MediaDecoder} and {@link SchemeHandler} appropriately
*/
@Deprecated
private Resources resources;
private ExecutorService executorService;
private Drawable errorDrawable;
// @since 1.1.0
private final List<MediaDecoder> mediaDecoders = new ArrayList<>(3);
// @since 2.0.0
private final Map<String, SchemeHandler> schemeHandlers = new HashMap<>(3);
/**
* @deprecated 2.0.0 add {@link NetworkSchemeHandler} directly
*/
@NonNull
@Deprecated
public Builder client(@NonNull OkHttpClient client) {
this.client = client;
return this;
@ -310,40 +242,148 @@ public class AsyncDrawableLoader implements AsyncDrawable.Loader {
return this;
}
/**
* @since 2.0.0
*/
@SuppressWarnings("UnusedReturnValue")
@NonNull
public Builder mediaDecoders(@NonNull List<MediaDecoder> mediaDecoders) {
this.mediaDecoders.clear();
this.mediaDecoders.addAll(mediaDecoders);
public Builder addSchemeHandler(@NonNull SchemeHandler schemeHandler) {
SchemeHandler previous;
for (String scheme : schemeHandler.schemes()) {
previous = schemeHandlers.put(scheme, schemeHandler);
if (previous != null) {
throw new IllegalStateException(String.format("Multiple scheme handlers handle " +
"the same scheme: `%s`, %s %s", scheme, previous, schemeHandler));
}
}
return this;
}
/**
* @see #addMediaDecoder(MediaDecoder)
* @see #addMediaDecoders(MediaDecoder...)
* @see #addMediaDecoders(Iterable)
* @since 1.1.0
* @deprecated 2.0.0
*/
@Deprecated
@NonNull
public Builder mediaDecoders(MediaDecoder... mediaDecoders) {
this.mediaDecoders.clear();
if (mediaDecoders != null
&& mediaDecoders.length > 0) {
Collections.addAll(this.mediaDecoders, mediaDecoders);
public Builder mediaDecoders(@NonNull List<MediaDecoder> mediaDecoders) {
// previously it was clearing before adding
for (MediaDecoder mediaDecoder : mediaDecoders) {
this.mediaDecoders.add(requireNonNull(mediaDecoder));
}
return this;
}
/**
* @see #addMediaDecoder(MediaDecoder)
* @see #addMediaDecoders(MediaDecoder...)
* @see #addMediaDecoders(Iterable)
* @since 1.1.0
* @deprecated 2.0.0
*/
@NonNull
@Deprecated
public Builder mediaDecoders(MediaDecoder... mediaDecoders) {
// previously it was clearing before adding
final int length = mediaDecoders != null
? mediaDecoders.length
: 0;
if (length > 0) {
for (int i = 0; i < length; i++) {
this.mediaDecoders.add(requireNonNull(mediaDecoders[i]));
}
}
return this;
}
/**
* @see SvgMediaDecoder
* @see GifMediaDecoder
* @see ImageMediaDecoder
* @since 2.0.0
*/
@NonNull
public Builder addMediaDecoder(@NonNull MediaDecoder mediaDecoder) {
mediaDecoders.add(mediaDecoder);
return this;
}
/**
* @see SvgMediaDecoder
* @see GifMediaDecoder
* @see ImageMediaDecoder
* @since 2.0.0
*/
@NonNull
public Builder addMediaDecoders(@NonNull Iterable<MediaDecoder> mediaDecoders) {
for (MediaDecoder mediaDecoder : mediaDecoders) {
this.mediaDecoders.add(requireNonNull(mediaDecoder));
}
return this;
}
/**
* @see SvgMediaDecoder
* @see GifMediaDecoder
* @see ImageMediaDecoder
* @since 2.0.0
*/
@NonNull
public Builder addMediaDecoders(MediaDecoder... mediaDecoders) {
final int length = mediaDecoders != null
? mediaDecoders.length
: 0;
if (length > 0) {
for (int i = 0; i < length; i++) {
this.mediaDecoders.add(requireNonNull(mediaDecoders[i]));
}
}
return this;
}
@NonNull
public AsyncDrawableLoader build() {
if (client == null) {
client = new OkHttpClient();
}
// I think we should deprecate this...
if (resources == null) {
resources = Resources.getSystem();
}
if (executorService == null) {
// we will use executor from okHttp
executorService = client.dispatcher().executorService();
// @since 2.0.0 we are using newCachedThreadPool instead
// of `okHttpClient.dispatcher().executorService()`
executorService = Executors.newCachedThreadPool();
}
// @since 2.0.0
// put default scheme handlers (to mimic previous behavior)
// remove in 3.0.0 with plugins
if (schemeHandlers.size() == 0) {
if (client == null) {
client = new OkHttpClient();
}
addSchemeHandler(NetworkSchemeHandler.create(client));
addSchemeHandler(FileSchemeHandler.createWithAssets(resources.getAssets()));
addSchemeHandler(DataUriSchemeHandler.create());
}
// add default media decoders if not specified
// remove in 3.0.0 with plugins
if (mediaDecoders.size() == 0) {
mediaDecoders.add(SvgMediaDecoder.create(resources));
mediaDecoders.add(GifMediaDecoder.create(true));
@ -354,16 +394,12 @@ public class AsyncDrawableLoader implements AsyncDrawable.Loader {
}
}
private static class Item {
final String fileName;
final String contentType;
final InputStream inputStream;
Item(@Nullable String fileName, @Nullable String contentType, @Nullable InputStream inputStream) {
this.fileName = fileName;
this.contentType = contentType;
this.inputStream = inputStream;
// @since 2.0.0
@NonNull
private static <T> T requireNonNull(@Nullable T t) {
if (t == null) {
throw new NullPointerException();
}
return t;
}
}

View File

@ -0,0 +1,60 @@
package ru.noties.markwon.il;
import android.support.annotation.Nullable;
public class DataUri {
private final String contentType;
private final boolean base64;
private final String data;
public DataUri(@Nullable String contentType, boolean base64, @Nullable String data) {
this.contentType = contentType;
this.base64 = base64;
this.data = data;
}
@Nullable
public String contentType() {
return contentType;
}
public boolean base64() {
return base64;
}
@Nullable
public String data() {
return data;
}
@Override
public String toString() {
return "DataUri{" +
"contentType='" + contentType + '\'' +
", base64=" + base64 +
", data='" + data + '\'' +
'}';
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
DataUri dataUri = (DataUri) o;
if (base64 != dataUri.base64) return false;
if (contentType != null ? !contentType.equals(dataUri.contentType) : dataUri.contentType != null)
return false;
return data != null ? data.equals(dataUri.data) : dataUri.data == null;
}
@Override
public int hashCode() {
int result = contentType != null ? contentType.hashCode() : 0;
result = 31 * result + (base64 ? 1 : 0);
result = 31 * result + (data != null ? data.hashCode() : 0);
return result;
}
}

Some files were not shown because too many files have changed in this diff Show More