V2.0.0 (#66)
* 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
2
.gitignore
vendored
@ -6,3 +6,5 @@
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
**/build
|
||||
**/dist
|
||||
**/node_modules
|
21
.travis.yml
Normal 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
@ -4,43 +4,40 @@
|
||||
|
||||
[](http://search.maven.org/#search|ga|1|g%3A%22ru.noties%22%20AND%20a%3A%22markwon%22)
|
||||
[](http://search.maven.org/#search|ga|1|g%3A%22ru.noties%22%20AND%20a%3A%22markwon-image-loader%22)
|
||||
[](http://search.maven.org/#search|ga|1|g%3A%22ru.noties%22%20AND%20a%3A%22markwon-syntax%22)
|
||||
[](http://search.maven.org/#search|ga|1|g%3A%22ru.noties%22%20AND%20a%3A%22markwon-syntax-highlight%22)
|
||||
[](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
|
||||

|
||||
|
||||
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 & table extensions
|
||||
* & 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
|
||||
|
@ -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']
|
||||
}
|
||||
}
|
||||
|
@ -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
@ -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 |
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
84
build.gradle
@ -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.®isterArtifact
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
34
docs/.vuepress/components/GithubIssue.vue
Normal 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>
|
||||
|
27
docs/.vuepress/components/GithubPull.vue
Normal 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>
|
||||
|
21
docs/.vuepress/components/GithubUser.vue
Normal 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>
|
||||
|
56
docs/.vuepress/components/Link.vue
Normal 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>
|
||||
|
19
docs/.vuepress/components/MavenBadge.vue
Normal 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>
|
||||
|
20
docs/.vuepress/components/MavenBadges.vue
Normal 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>
|
||||
|
||||
|
15
docs/.vuepress/components/ThemeProperty.vue
Normal 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
@ -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'));
|
||||
}
|
||||
}
|
||||
}
|
2
docs/.vuepress/override.styl
Normal file
@ -0,0 +1,2 @@
|
||||
$textColor = #000000
|
||||
$accentColor = #4CAF50
|
BIN
docs/.vuepress/public/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 7.7 KiB |
BIN
docs/.vuepress/public/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
docs/.vuepress/public/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 7.2 KiB |
1
docs/.vuepress/public/art
Symbolic link
@ -0,0 +1 @@
|
||||
../../../art/
|
BIN
docs/.vuepress/public/favicon-16x16.png
Normal file
After Width: | Height: | Size: 712 B |
BIN
docs/.vuepress/public/favicon-32x32.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
docs/.vuepress/public/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
17
docs/.vuepress/public/manifest.json
Normal 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"
|
||||
}
|
0
docs/.vuepress/style.styl
Normal 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
@ -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 & 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
|
@ -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
|
@ -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
@ -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` & `file` schemes
|
||||
and 2 themes included: Light & Dark. It can be downloaded from [releases](https://github.com/noties/Markwon/releases)
|
||||
:::
|
@ -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
|
@ -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
|
@ -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
|
@ -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
@ -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
@ -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: `` 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: `` 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
@ -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)
|
||||
};
|
||||
}
|
||||
```
|
||||
:::
|
97
docs/docs/getting-started.md
Normal 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
|
||||
& 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
@ -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 & 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>` &
|
||||
`<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 & 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
@ -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
|
||||

|
||||
```
|
||||
:::
|
||||
|
||||
## 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
@ -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
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
69
docs/docs/syntax-highlight.md
Normal 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
@ -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 & H2
|
||||
|
||||
<ThemeProperty name="headingBreakHeight" type="@Px int" defaults="Stroke width of context TextPaint" />
|
||||
|
||||
### Break color
|
||||
|
||||
The color of a brake under H1 & 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) & `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
@ -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
9
docs/package.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"scripts": {
|
||||
"docs:build": "vuepress build"
|
||||
},
|
||||
"dependencies": {
|
||||
"markdown-it-task-lists": "^2.1.1",
|
||||
"vuepress": "^0.14.2"
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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'
|
||||
}
|
@ -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'
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
POM_NAME=Markwon
|
||||
POM_ARTIFACT_ID=markwon-syntax
|
||||
POM_PACKAGING=aar
|
@ -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'
|
||||
}
|
@ -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'
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
23
markwon-html-parser-api/build.gradle
Normal 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)
|
3
markwon-html-parser-api/gradle.properties
Normal file
@ -0,0 +1,3 @@
|
||||
POM_NAME=Markwon
|
||||
POM_ARTIFACT_ID=markwon-html-parser-api
|
||||
POM_PACKAGING=aar
|
1
markwon-html-parser-api/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1 @@
|
||||
<manifest package="ru.noties.markwon.html.api" />
|
@ -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();
|
||||
}
|
||||
}
|
@ -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();
|
||||
|
||||
}
|
@ -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() {
|
||||
|
||||
}
|
||||
}
|
31
markwon-html-parser-impl/build.gradle
Normal 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)
|
3
markwon-html-parser-impl/gradle.properties
Normal file
@ -0,0 +1,3 @@
|
||||
POM_NAME=Markwon
|
||||
POM_ARTIFACT_ID=markwon-html-parser-impl
|
||||
POM_PACKAGING=aar
|
1
markwon-html-parser-impl/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1 @@
|
||||
<manifest package="ru.noties.markwon.html.impl" />
|
@ -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() {
|
||||
}
|
||||
}
|
@ -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 & 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;
|
||||
}
|
||||
}
|
@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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, ' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
// }
|
||||
}
|
@ -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() {
|
||||
}
|
||||
}
|
@ -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));
|
||||
// }
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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();
|
||||
// }
|
||||
}
|
@ -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));
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
@ -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]);
|
||||
}
|
||||
}
|
37
markwon-image-loader/build.gradle
Normal 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)
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|