Use MD4C to convert markdown to HTML

This commit is contained in:
William Brawner 2024-05-04 10:37:33 -06:00
parent 37f0b8bae8
commit 208e0a1a6f
Signed by: wbrawner
GPG key ID: 8FF12381C6C90D35
21 changed files with 188 additions and 3802 deletions

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "md4k/lib/md4c"]
path = md4k/lib/md4c
url = https://github.com/mity/md4c

View file

@ -111,6 +111,7 @@ play {
} }
dependencies { dependencies {
implementation(project(":md4k"))
implementation("androidx.compose.material3:material3-window-size-class-android:1.2.0") implementation("androidx.compose.material3:material3-window-size-class-android:1.2.0")
val navigationVersion = "2.7.2" val navigationVersion = "2.7.2"
implementation("androidx.navigation:navigation-fragment-ktx:$navigationVersion") implementation("androidx.navigation:navigation-fragment-ktx:$navigationVersion")

View file

@ -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

View file

@ -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)
}

View file

@ -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 = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
};
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

View file

@ -8,7 +8,6 @@ import com.wbrawner.simplemarkdown.utility.FileHelper
import com.wbrawner.simplemarkdown.utility.Preference import com.wbrawner.simplemarkdown.utility.Preference
import com.wbrawner.simplemarkdown.utility.PreferenceHelper import com.wbrawner.simplemarkdown.utility.PreferenceHelper
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch

View file

@ -300,7 +300,7 @@ private fun MainScreen(
.width(1.dp) .width(1.dp)
.background(color = MaterialTheme.colorScheme.primary) .background(color = MaterialTheme.colorScheme.primary)
) )
MarkdownPreview( MarkdownText(
modifier = Modifier modifier = Modifier
.fillMaxHeight() .fillMaxHeight()
.weight(1f), .weight(1f),
@ -362,7 +362,7 @@ private fun TabbedMarkdownEditor(
enableReadability = enableReadability enableReadability = enableReadability
) )
} else { } else {
MarkdownPreview(modifier = Modifier.fillMaxSize(), markdown) MarkdownText(modifier = Modifier.fillMaxSize(), markdown)
} }
} }
} }
@ -436,30 +436,34 @@ fun MarkdownTopAppBar(
actions: (@Composable RowScope.() -> Unit)? = null actions: (@Composable RowScope.() -> Unit)? = null
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
TopAppBar(title = { TopAppBar(
Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) title = {
}, navigationIcon = { Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis)
val (icon, contentDescription, onClick) = remember { },
if (backAsUp) { navigationIcon = {
Triple(Icons.AutoMirrored.Filled.ArrowBack, "Go Back", goBack) val (icon, contentDescription, onClick) = remember {
} else { if (backAsUp) {
Triple( Triple(Icons.AutoMirrored.Filled.ArrowBack, "Go Back", goBack)
Icons.Default.Menu, "Main Menu" } else {
) { Triple(
coroutineScope.launch { Icons.Default.Menu, "Main Menu"
if (drawerState?.isOpen == true) { ) {
drawerState.close() coroutineScope.launch {
} else { if (drawerState?.isOpen == true) {
drawerState?.open() drawerState.close()
} else {
drawerState?.open()
}
} }
} }
} }
} }
} IconButton(onClick = { onClick() }) {
IconButton(onClick = { onClick() }) { Icon(imageVector = icon, contentDescription = contentDescription)
Icon(imageVector = icon, contentDescription = contentDescription) }
} }, actions = actions ?: {},
}, actions = actions ?: {}) scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
)
} }
@Composable @Composable

View file

@ -31,7 +31,7 @@ fun MarkdownInfoScreen(
LaunchedEffect(file) { LaunchedEffect(file) {
setMarkdown(context.assets.readAssetToString(file) ?: "Failed to load $file") setMarkdown(context.assets.readAssetToString(file) ?: "Failed to load $file")
} }
MarkdownPreview( MarkdownText(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues), .padding(paddingValues),

View file

@ -1,31 +1,36 @@
package com.wbrawner.simplemarkdown.ui package com.wbrawner.simplemarkdown.ui
import android.content.Context
import android.view.ViewGroup import android.view.ViewGroup
import android.webkit.WebView import android.webkit.WebView
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import com.wbrawner.md4k.MD4K
import com.wbrawner.simplemarkdown.BuildConfig import com.wbrawner.simplemarkdown.BuildConfig
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext 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) @OptIn(ExperimentalStdlibApi::class)
@Composable @Composable
fun MarkdownPreview(modifier: Modifier = Modifier, markdown: String) { fun HtmlText(html: String, modifier: Modifier = Modifier) {
val materialColors = MaterialTheme.colorScheme val materialColors = MaterialTheme.colorScheme
val style = remember(isSystemInDarkTheme()) { val style = remember(isSystemInDarkTheme()) {
"""body { """body {
@ -37,37 +42,10 @@ fun MarkdownPreview(modifier: Modifier = Modifier, markdown: String) {
| color: #${materialColors.onSurfaceVariant.toArgb().toHexString().substring(2)}; | color: #${materialColors.onSurfaceVariant.toArgb().toHexString().substring(2)};
|}""".trimMargin().wrapTag("style") |}""".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( AndroidView(
modifier = modifier, modifier = modifier,
factory = { context -> factory = { context ->
val content =
highlightCss + style + container + marked + markedHighlight + highlightJs + markdownJs + markdownUpdateJs
WebView(context).apply { WebView(context).apply {
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG) WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
layoutParams = ViewGroup.LayoutParams( layoutParams = ViewGroup.LayoutParams(
@ -77,18 +55,13 @@ fun MarkdownPreview(modifier: Modifier = Modifier, markdown: String) {
setBackgroundColor(Color.Transparent.toArgb()) setBackgroundColor(Color.Transparent.toArgb())
isNestedScrollingEnabled = false isNestedScrollingEnabled = false
settings.javaScriptEnabled = true settings.javaScriptEnabled = true
loadDataWithBaseURL(null, content, "text/html", "UTF-8", null) loadDataWithBaseURL(null, style + html, "text/html", "UTF-8", null)
} }
}, },
update = { webView -> update = { webView ->
val content = webView.loadDataWithBaseURL(null, style + html, "text/html", "UTF-8", null)
highlightCss + style + container + marked + markedHighlight + highlightJs + markdownJs + markdownUpdateJs
webView.loadDataWithBaseURL(null, content, "text/html", "UTF-8", null)
} }
) )
} }
private fun String.wrapTag(tag: String) = "<$tag>$this</$tag>" 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
View file

@ -0,0 +1,2 @@
/build
/.cxx

18
md4k/CMakeLists.txt Normal file
View 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
View 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
View file

1
md4k/lib/md4c Submodule

@ -0,0 +1 @@
Subproject commit 481fbfbdf72daab2912380d62bb5f2187d438408

21
md4k/proguard-rules.pro vendored Normal file
View 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

View 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
View 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;
}

View 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)

View file

@ -1 +1 @@
include(":app") include(":app", ":md4k")