Use MD4C to convert markdown to HTML
This commit is contained in:
parent
37f0b8bae8
commit
208e0a1a6f
21 changed files with 188 additions and 3802 deletions
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
[submodule "md4k/lib/md4c"]
|
||||
path = md4k/lib/md4c
|
||||
url = https://github.com/mity/md4c
|
|
@ -111,6 +111,7 @@ play {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":md4k"))
|
||||
implementation("androidx.compose.material3:material3-window-size-class-android:1.2.0")
|
||||
val navigationVersion = "2.7.2"
|
||||
implementation("androidx.navigation:navigation-fragment-ktx:$navigationVersion")
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
/*!
|
||||
Theme: Default
|
||||
Description: Original highlight.js style
|
||||
Author: (c) Ivan Sagalaev <maniac@softwaremaniacs.org>
|
||||
Maintainer: @highlightjs/core-team
|
||||
Website: https://highlightjs.org/
|
||||
License: see project LICENSE
|
||||
Touched: 2021
|
||||
*/pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#f3f3f3;color:#444}.hljs-comment{color:#697070}.hljs-punctuation,.hljs-tag{color:#444a}.hljs-tag .hljs-attr,.hljs-tag .hljs-name{color:#444}.hljs-attribute,.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-name,.hljs-selector-tag{font-weight:700}.hljs-deletion,.hljs-number,.hljs-quote,.hljs-selector-class,.hljs-selector-id,.hljs-string,.hljs-template-tag,.hljs-type{color:#800}.hljs-section,.hljs-title{color:#800;font-weight:700}.hljs-link,.hljs-operator,.hljs-regexp,.hljs-selector-attr,.hljs-selector-pseudo,.hljs-symbol,.hljs-template-variable,.hljs-variable{color:#ab5656}.hljs-literal{color:#695}.hljs-addition,.hljs-built_in,.hljs-bullet,.hljs-code{color:#397300}.hljs-meta{color:#1f7199}.hljs-meta .hljs-string{color:#38a}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}
|
File diff suppressed because one or more lines are too long
|
@ -1,14 +0,0 @@
|
|||
const localMarked = new marked.Marked(
|
||||
markedHighlight.markedHighlight({
|
||||
langPrefix: 'hljs language-',
|
||||
highlight(code, lang) {
|
||||
console.log(`highlighing code with $lang`)
|
||||
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
|
||||
return hljs.highlight(code, { language }).value;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
function setMarkdown(markdown) {
|
||||
document.getElementById('content').innerHTML = marked.parse(markdown)
|
||||
}
|
|
@ -1,96 +0,0 @@
|
|||
(function (global, factory) {
|
||||
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
|
||||
typeof define === 'function' && define.amd ? define(['exports'], factory) :
|
||||
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.markedHighlight = {}));
|
||||
})(this, (function (exports) { 'use strict';
|
||||
|
||||
function markedHighlight(options) {
|
||||
if (typeof options === 'function') {
|
||||
options = {
|
||||
highlight: options
|
||||
};
|
||||
}
|
||||
|
||||
if (!options || typeof options.highlight !== 'function') {
|
||||
throw new Error('Must provide highlight function');
|
||||
}
|
||||
|
||||
if (typeof options.langPrefix !== 'string') {
|
||||
options.langPrefix = 'language-';
|
||||
}
|
||||
|
||||
return {
|
||||
async: !!options.async,
|
||||
walkTokens(token) {
|
||||
if (token.type !== 'code') {
|
||||
return;
|
||||
}
|
||||
|
||||
const lang = getLang(token);
|
||||
|
||||
if (options.async) {
|
||||
return Promise.resolve(options.highlight(token.text, lang)).then(updateToken(token));
|
||||
}
|
||||
|
||||
const code = options.highlight(token.text, lang);
|
||||
if (code instanceof Promise) {
|
||||
throw new Error('markedHighlight is not set to async but the highlight function is async. Set the async option to true on markedHighlight to await the async highlight function.');
|
||||
}
|
||||
updateToken(token)(code);
|
||||
},
|
||||
renderer: {
|
||||
code(code, infoString, escaped) {
|
||||
const lang = (infoString || '').match(/\S*/)[0];
|
||||
const classAttr = lang
|
||||
? ` class="${options.langPrefix}${escape(lang)}"`
|
||||
: '';
|
||||
code = code.replace(/\n$/, '');
|
||||
return `<pre><code${classAttr}>${escaped ? code : escape(code, true)}\n</code></pre>`;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getLang(token) {
|
||||
return (token.lang || '').match(/\S*/)[0];
|
||||
}
|
||||
|
||||
function updateToken(token) {
|
||||
return (code) => {
|
||||
if (typeof code === 'string' && code !== token.text) {
|
||||
token.escaped = true;
|
||||
token.text = code;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// copied from marked helpers
|
||||
const escapeTest = /[&<>"']/;
|
||||
const escapeReplace = new RegExp(escapeTest.source, 'g');
|
||||
const escapeTestNoEncode = /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/;
|
||||
const escapeReplaceNoEncode = new RegExp(escapeTestNoEncode.source, 'g');
|
||||
const escapeReplacements = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
const getEscapeReplacement = (ch) => escapeReplacements[ch];
|
||||
function escape(html, encode) {
|
||||
if (encode) {
|
||||
if (escapeTest.test(html)) {
|
||||
return html.replace(escapeReplace, getEscapeReplacement);
|
||||
}
|
||||
} else {
|
||||
if (escapeTestNoEncode.test(html)) {
|
||||
return html.replace(escapeReplaceNoEncode, getEscapeReplacement);
|
||||
}
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
exports.markedHighlight = markedHighlight;
|
||||
|
||||
}));
|
File diff suppressed because it is too large
Load diff
|
@ -8,7 +8,6 @@ import com.wbrawner.simplemarkdown.utility.FileHelper
|
|||
import com.wbrawner.simplemarkdown.utility.Preference
|
||||
import com.wbrawner.simplemarkdown.utility.PreferenceHelper
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
|
@ -300,7 +300,7 @@ private fun MainScreen(
|
|||
.width(1.dp)
|
||||
.background(color = MaterialTheme.colorScheme.primary)
|
||||
)
|
||||
MarkdownPreview(
|
||||
MarkdownText(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.weight(1f),
|
||||
|
@ -362,7 +362,7 @@ private fun TabbedMarkdownEditor(
|
|||
enableReadability = enableReadability
|
||||
)
|
||||
} else {
|
||||
MarkdownPreview(modifier = Modifier.fillMaxSize(), markdown)
|
||||
MarkdownText(modifier = Modifier.fillMaxSize(), markdown)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -436,30 +436,34 @@ fun MarkdownTopAppBar(
|
|||
actions: (@Composable RowScope.() -> Unit)? = null
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
TopAppBar(title = {
|
||||
Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}, navigationIcon = {
|
||||
val (icon, contentDescription, onClick) = remember {
|
||||
if (backAsUp) {
|
||||
Triple(Icons.AutoMirrored.Filled.ArrowBack, "Go Back", goBack)
|
||||
} else {
|
||||
Triple(
|
||||
Icons.Default.Menu, "Main Menu"
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
if (drawerState?.isOpen == true) {
|
||||
drawerState.close()
|
||||
} else {
|
||||
drawerState?.open()
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
navigationIcon = {
|
||||
val (icon, contentDescription, onClick) = remember {
|
||||
if (backAsUp) {
|
||||
Triple(Icons.AutoMirrored.Filled.ArrowBack, "Go Back", goBack)
|
||||
} else {
|
||||
Triple(
|
||||
Icons.Default.Menu, "Main Menu"
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
if (drawerState?.isOpen == true) {
|
||||
drawerState.close()
|
||||
} else {
|
||||
drawerState?.open()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { onClick() }) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription)
|
||||
}
|
||||
}, actions = actions ?: {})
|
||||
IconButton(onClick = { onClick() }) {
|
||||
Icon(imageVector = icon, contentDescription = contentDescription)
|
||||
}
|
||||
}, actions = actions ?: {},
|
||||
scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
@ -31,7 +31,7 @@ fun MarkdownInfoScreen(
|
|||
LaunchedEffect(file) {
|
||||
setMarkdown(context.assets.readAssetToString(file) ?: "Failed to load $file")
|
||||
}
|
||||
MarkdownPreview(
|
||||
MarkdownText(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
|
|
|
@ -1,31 +1,36 @@
|
|||
package com.wbrawner.simplemarkdown.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.WebView
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.wbrawner.md4k.MD4K
|
||||
import com.wbrawner.simplemarkdown.BuildConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.Reader
|
||||
|
||||
private const val container = "<main id=\"content\"></main>"
|
||||
@Composable
|
||||
fun MarkdownText(modifier: Modifier = Modifier, markdown: String) {
|
||||
val (html, setHtml) = remember { mutableStateOf("") }
|
||||
LaunchedEffect(markdown) {
|
||||
withContext(Dispatchers.IO) {
|
||||
setHtml(MD4K.toHtml(markdown))
|
||||
}
|
||||
}
|
||||
HtmlText(modifier = modifier, html = html)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
@Composable
|
||||
fun MarkdownPreview(modifier: Modifier = Modifier, markdown: String) {
|
||||
fun HtmlText(html: String, modifier: Modifier = Modifier) {
|
||||
val materialColors = MaterialTheme.colorScheme
|
||||
val style = remember(isSystemInDarkTheme()) {
|
||||
"""body {
|
||||
|
@ -37,37 +42,10 @@ fun MarkdownPreview(modifier: Modifier = Modifier, markdown: String) {
|
|||
| color: #${materialColors.onSurfaceVariant.toArgb().toHexString().substring(2)};
|
||||
|}""".trimMargin().wrapTag("style")
|
||||
}
|
||||
var marked by remember { mutableStateOf("") }
|
||||
var markedHighlight by remember { mutableStateOf("") }
|
||||
var highlightJs by remember { mutableStateOf("") }
|
||||
var highlightCss by remember { mutableStateOf("") }
|
||||
var markdownJs by remember { mutableStateOf("") }
|
||||
val markdownUpdateJs by remember(markdown) {
|
||||
mutableStateOf(
|
||||
"setMarkdown(`${
|
||||
markdown.replace(
|
||||
"`",
|
||||
"\\`"
|
||||
)
|
||||
}`)".wrapTag("script")
|
||||
)
|
||||
}
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(context) {
|
||||
withContext(Dispatchers.IO) {
|
||||
marked = context.assetToString("marked.js").wrapTag("script")
|
||||
markedHighlight = context.assetToString("marked-highlight.js").wrapTag("script")
|
||||
highlightJs = context.assetToString("highlight.js").wrapTag("script")
|
||||
highlightCss = context.assetToString("highlight.css").wrapTag("style")
|
||||
markdownJs = context.assetToString("markdown.js").wrapTag("script")
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
modifier = modifier,
|
||||
factory = { context ->
|
||||
val content =
|
||||
highlightCss + style + container + marked + markedHighlight + highlightJs + markdownJs + markdownUpdateJs
|
||||
|
||||
WebView(context).apply {
|
||||
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
|
@ -77,18 +55,13 @@ fun MarkdownPreview(modifier: Modifier = Modifier, markdown: String) {
|
|||
setBackgroundColor(Color.Transparent.toArgb())
|
||||
isNestedScrollingEnabled = false
|
||||
settings.javaScriptEnabled = true
|
||||
loadDataWithBaseURL(null, content, "text/html", "UTF-8", null)
|
||||
loadDataWithBaseURL(null, style + html, "text/html", "UTF-8", null)
|
||||
}
|
||||
},
|
||||
update = { webView ->
|
||||
val content =
|
||||
highlightCss + style + container + marked + markedHighlight + highlightJs + markdownJs + markdownUpdateJs
|
||||
webView.loadDataWithBaseURL(null, content, "text/html", "UTF-8", null)
|
||||
webView.loadDataWithBaseURL(null, style + html, "text/html", "UTF-8", null)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.wrapTag(tag: String) = "<$tag>$this</$tag>"
|
||||
|
||||
private fun Context.assetToString(fileName: String): String =
|
||||
assets.open(fileName).reader().use(Reader::readText)
|
2
md4k/.gitignore
vendored
Normal file
2
md4k/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
/build
|
||||
/.cxx
|
18
md4k/CMakeLists.txt
Normal file
18
md4k/CMakeLists.txt
Normal file
|
@ -0,0 +1,18 @@
|
|||
cmake_minimum_required(VERSION 3.22.1)
|
||||
|
||||
project("md4k")
|
||||
|
||||
add_subdirectory("lib/md4c")
|
||||
set(BUILD_MD2HTML_EXECUTABLE OFF)
|
||||
|
||||
add_library(${CMAKE_PROJECT_NAME} SHARED
|
||||
src/main/cpp/md4k.c)
|
||||
|
||||
target_link_libraries(${CMAKE_PROJECT_NAME}
|
||||
md4c
|
||||
md4c-html
|
||||
)
|
||||
|
||||
target_include_directories(${CMAKE_PROJECT_NAME} PUBLIC
|
||||
${PROJECT_SOURCE_DIR}/lib/md4c/src
|
||||
)
|
41
md4k/build.gradle.kts
Normal file
41
md4k/build.gradle.kts
Normal file
|
@ -0,0 +1,41 @@
|
|||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.wbrawner.md4k"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 23
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
cppFlags("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path("CMakeLists.txt")
|
||||
version = "3.22.1"
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
}
|
0
md4k/consumer-rules.pro
Normal file
0
md4k/consumer-rules.pro
Normal file
1
md4k/lib/md4c
Submodule
1
md4k/lib/md4c
Submodule
|
@ -0,0 +1 @@
|
|||
Subproject commit 481fbfbdf72daab2912380d62bb5f2187d438408
|
21
md4k/proguard-rules.pro
vendored
Normal file
21
md4k/proguard-rules.pro
vendored
Normal file
|
@ -0,0 +1,21 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
4
md4k/src/main/AndroidManifest.xml
Normal file
4
md4k/src/main/AndroidManifest.xml
Normal file
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
</manifest>
|
41
md4k/src/main/cpp/md4k.c
Normal file
41
md4k/src/main/cpp/md4k.c
Normal file
|
@ -0,0 +1,41 @@
|
|||
#include <jni.h>
|
||||
#include "md4c.h"
|
||||
#include "md4c-html.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef struct {
|
||||
char *data;
|
||||
size_t length;
|
||||
} HtmlBuffer;
|
||||
|
||||
void process_output(const MD_CHAR *output, MD_SIZE size, void *userdata) {
|
||||
HtmlBuffer *buffer = (HtmlBuffer *) userdata;
|
||||
buffer->data = realloc(buffer->data, buffer->length + size);
|
||||
memcpy(buffer->data + buffer->length, output, size);
|
||||
buffer->length += size;
|
||||
}
|
||||
|
||||
jstring Java_com_wbrawner_md4k_MD4K_toHtml(
|
||||
JNIEnv *env,
|
||||
jobject this,
|
||||
jstring markdown
|
||||
) {
|
||||
const char *nativeString = (*env)->GetStringUTFChars(env, markdown, NULL);
|
||||
HtmlBuffer buffer;
|
||||
buffer.data = malloc(0);
|
||||
buffer.length = 0;
|
||||
md_html(
|
||||
nativeString,
|
||||
strlen(nativeString),
|
||||
&process_output,
|
||||
&buffer,
|
||||
MD_FLAG_PERMISSIVEAUTOLINKS | MD_FLAG_TABLES | MD_FLAG_STRIKETHROUGH |
|
||||
MD_FLAG_TASKLISTS | MD_FLAG_LATEXMATHSPANS | MD_FLAG_WIKILINKS | MD_FLAG_UNDERLINE,
|
||||
0
|
||||
);
|
||||
(*env)->ReleaseStringUTFChars(env, markdown, nativeString);
|
||||
jstring html = (*env)->NewStringUTF(env, buffer.data);
|
||||
free(buffer.data);
|
||||
return html;
|
||||
}
|
12
md4k/src/main/java/com/wbrawner/md4k/MD4K.kt
Normal file
12
md4k/src/main/java/com/wbrawner/md4k/MD4K.kt
Normal file
|
@ -0,0 +1,12 @@
|
|||
package com.wbrawner.md4k
|
||||
|
||||
object MD4K {
|
||||
|
||||
init {
|
||||
System.loadLibrary("md4k")
|
||||
}
|
||||
|
||||
external fun toHtml(markdown: String): String
|
||||
}
|
||||
|
||||
fun String.toHtml() = MD4K.toHtml(this)
|
|
@ -1 +1 @@
|
|||
include(":app")
|
||||
include(":app", ":md4k")
|
||||
|
|
Loading…
Reference in a new issue