Skip to content

Commit

Permalink
Add initial markdown to docx export support.
Browse files Browse the repository at this point in the history
  • Loading branch information
soupslurpr committed Aug 16, 2023
1 parent f05ab89 commit 8c89d2c
Show file tree
Hide file tree
Showing 13 changed files with 483 additions and 71 deletions.
Binary file modified app/src/main/assets/beautyxt_rs-third-party-licenses.html
Binary file not shown.
Binary file modified app/src/main/jniLibs/arm64-v8a/libbeautyxt_rs.so
Binary file not shown.
Binary file modified app/src/main/jniLibs/x86_64/libbeautyxt_rs.so
Binary file not shown.
23 changes: 22 additions & 1 deletion app/src/main/kotlin/dev/soupslurpr/beautyxt/BeauTyXT.kt
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,13 @@ fun BeauTyXTApp(
}
}

val mimeTypeDocx = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"

val saveAsDocxFileLauncher = rememberLauncherForActivityResult(contract = CreateDocument(mimeTypeDocx)) {
if (it != null) {
fileViewModel.saveAsDocx(it, context)
}
}
var previewMarkdownRenderedToFullscreen by rememberSaveable { mutableStateOf(false) }

val randomValue = Random.nextInt(0, 10)
Expand Down Expand Up @@ -456,7 +463,8 @@ fun BeauTyXTApp(
Column(
modifier = Modifier
.selectableGroup()
.fillMaxWidth()
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
SaveAsDialogItem(
fileTypeText = stringResource(R.string.html),
Expand All @@ -465,6 +473,15 @@ fun BeauTyXTApp(
saveAsSelectedFileType = mimeTypeHtml
}
)
if (preferencesUiState.experimentalFeatureExportMarkdownToDocx.second.value) {
SaveAsDialogItem(
fileTypeText = stringResource(R.string.docx),
selected = saveAsSelectedFileType == mimeTypeDocx,
onClickRadioButton = {
saveAsSelectedFileType = mimeTypeDocx
}
)
}
}

},
Expand All @@ -475,6 +492,10 @@ fun BeauTyXTApp(
mimeTypeHtml -> saveAsHtmlFileLauncher.launch(
fileUiState.name.value.substringBeforeLast(".")
)

mimeTypeDocx -> saveAsDocxFileLauncher.launch(
fileUiState.name.value.substringBeforeLast(".")
)
}
saveAsShown = false
},
Expand Down
94 changes: 78 additions & 16 deletions app/src/main/kotlin/dev/soupslurpr/beautyxt/beautyxt_rs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

@file:Suppress("NAME_SHADOWING")

package dev.soupslurpr.beautyxt;
package dev.soupslurpr.beautyxt

// Common helper code.
//
Expand All @@ -17,13 +17,12 @@ package dev.soupslurpr.beautyxt;
// compile the Rust component. The easiest way to ensure this is to bundle the Kotlin
// helpers directly inline like we're doing here.

import com.sun.jna.Library
import com.sun.jna.IntegerType
import com.sun.jna.Library
import com.sun.jna.Native
import com.sun.jna.Pointer
import com.sun.jna.Structure
import com.sun.jna.Callback
import com.sun.jna.ptr.*
import com.sun.jna.ptr.ByReference
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.CharBuffer
Expand All @@ -44,15 +43,15 @@ open class RustBuffer : Structure() {
class ByReference: RustBuffer(), Structure.ByReference

companion object {
internal fun alloc(size: Int = 0) = rustCall() { status ->
internal fun alloc(size: Int = 0) = rustCall { status ->
_UniFFILib.INSTANCE.ffi_beautyxt_rs_rustbuffer_alloc(size, status).also {
if(it.data == null) {
throw RuntimeException("RustBuffer.alloc() returned null data pointer (size=${size})")
}
}
}

internal fun free(buf: RustBuffer.ByValue) = rustCall() { status ->
internal fun free(buf: ByValue) = rustCall { status ->
_UniFFILib.INSTANCE.ffi_beautyxt_rs_rustbuffer_free(buf, status)
}
}
Expand All @@ -76,7 +75,7 @@ class RustBufferByReference : ByReference(16) {
*/
fun setValue(value: RustBuffer.ByValue) {
// NOTE: The offsets are as they are in the C-like struct.
val pointer = getPointer()
val pointer = pointer
pointer.setInt(0, value.capacity)
pointer.setInt(4, value.len)
pointer.setPointer(8, value.data)
Expand All @@ -86,7 +85,7 @@ class RustBufferByReference : ByReference(16) {
* Get a `RustBuffer.ByValue` from this reference.
*/
fun getValue(): RustBuffer.ByValue {
val pointer = getPointer()
val pointer = pointer
val value = RustBuffer.ByValue()
value.writeField("capacity", pointer.getInt(0))
value.writeField("len", pointer.getInt(4))
Expand All @@ -113,7 +112,7 @@ open class ForeignBytes : Structure() {
//
// All implementing objects should be public to support external types. When a
// type is external we need to import it's FfiConverter.
public interface FfiConverter<KotlinType, FfiType> {
interface FfiConverter<KotlinType, FfiType> {
// Convert an FFI type to a Kotlin type
fun lift(value: FfiType): KotlinType

Expand Down Expand Up @@ -176,7 +175,7 @@ public interface FfiConverter<KotlinType, FfiType> {
}

// FfiConverter that uses `RustBuffer` as the FfiType
public interface FfiConverterRustBuffer<KotlinType>: FfiConverter<KotlinType, RustBuffer.ByValue> {
interface FfiConverterRustBuffer<KotlinType>: FfiConverter<KotlinType, RustBuffer.ByValue> {
override fun lift(value: RustBuffer.ByValue) = liftFromRustBuffer(value)
override fun lower(value: KotlinType) = lowerIntoRustBuffer(value)
}
Expand Down Expand Up @@ -207,7 +206,7 @@ class InternalException(message: String) : Exception(message)

// Each top-level error class has a companion object that can lift the error from the call status's rust buffer
interface CallStatusErrorHandler<E> {
fun lift(error_buf: RustBuffer.ByValue): E;
fun lift(error_buf: RustBuffer.ByValue): E
}

// Helpers for calling Rust
Expand All @@ -216,7 +215,7 @@ interface CallStatusErrorHandler<E> {

// Call a rust function that returns a Result<>. Pass in the Error class companion that corresponds to the Err
private inline fun <U, E: Exception> rustCallWithError(errorHandler: CallStatusErrorHandler<E>, callback: (RustCallStatus) -> U): U {
var status = RustCallStatus();
var status = RustCallStatus()
val return_value = callback(status)
checkCallStatus(errorHandler, status)
return return_value
Expand Down Expand Up @@ -252,11 +251,11 @@ object NullCallStatusErrorHandler: CallStatusErrorHandler<InternalException> {

// Call a rust function that returns a plain value
private inline fun <U> rustCall(callback: (RustCallStatus) -> U): U {
return rustCallWithError(NullCallStatusErrorHandler, callback);
return rustCallWithError(NullCallStatusErrorHandler, callback)
}

// IntegerType that matches Rust's `usize` / C's `size_t`
public class USize(value: Long = 0) : IntegerType(Native.SIZE_T_SIZE, value, true) {
class USize(value: Long = 0) : IntegerType(Native.SIZE_T_SIZE, value, true) {
// This is needed to fill in the gaps of IntegerType's implementation of Number for Kotlin.
override fun toByte() = toInt().toByte()
// Needed until https://youtrack.jetbrains.com/issue/KT-47902 is fixed.
Expand Down Expand Up @@ -368,6 +367,9 @@ internal interface _UniFFILib : Library {
}
}

fun uniffi_beautyxt_rs_fn_func_markdown_to_docx(
`markdown`: RustBuffer.ByValue, _uniffi_out_err: RustCallStatus,
): RustBuffer.ByValue
fun uniffi_beautyxt_rs_fn_func_markdown_to_html(`markdown`: RustBuffer.ByValue,_uniffi_out_err: RustCallStatus,
): RustBuffer.ByValue
fun ffi_beautyxt_rs_rustbuffer_alloc(`size`: Int,_uniffi_out_err: RustCallStatus,
Expand All @@ -378,6 +380,9 @@ internal interface _UniFFILib : Library {
): Unit
fun ffi_beautyxt_rs_rustbuffer_reserve(`buf`: RustBuffer.ByValue,`additional`: Int,_uniffi_out_err: RustCallStatus,
): RustBuffer.ByValue

fun uniffi_beautyxt_rs_checksum_func_markdown_to_docx(
): Short
fun uniffi_beautyxt_rs_checksum_func_markdown_to_html(
): Short
fun ffi_beautyxt_rs_uniffi_contract_version(
Expand All @@ -397,6 +402,9 @@ private fun uniffiCheckContractApiVersion(lib: _UniFFILib) {

@Suppress("UNUSED_PARAMETER")
private fun uniffiCheckApiChecksums(lib: _UniFFILib) {
if (lib.uniffi_beautyxt_rs_checksum_func_markdown_to_docx() != 19396.toShort()) {
throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
}
if (lib.uniffi_beautyxt_rs_checksum_func_markdown_to_html() != 42103.toShort()) {
throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project")
}
Expand All @@ -405,7 +413,27 @@ private fun uniffiCheckApiChecksums(lib: _UniFFILib) {
// Public interface members begin here.


public object FfiConverterString: FfiConverter<String, RustBuffer.ByValue> {
object FfiConverterUByte : FfiConverter<UByte, Byte> {
override fun lift(value: Byte): UByte {
return value.toUByte()
}

override fun read(buf: ByteBuffer): UByte {
return lift(buf.get())
}

override fun lower(value: UByte): Byte {
return value.toByte()
}

override fun allocationSize(value: UByte) = 1

override fun write(value: UByte, buf: ByteBuffer) {
buf.put(value.toByte())
}
}

object FfiConverterString: FfiConverter<String, RustBuffer.ByValue> {
// Note: we don't inherit from FfiConverterRustBuffer, because we use a
// special encoding when lowering/lifting. We can use `RustBuffer.len` to
// store our length and avoid writing it out to the buffer.
Expand Down Expand Up @@ -459,9 +487,43 @@ public object FfiConverterString: FfiConverter<String, RustBuffer.ByValue> {
}
}


object FfiConverterSequenceUByte : FfiConverterRustBuffer<List<UByte>> {
override fun read(buf: ByteBuffer): List<UByte> {
val len = buf.getInt()
return List<UByte>(len) {
FfiConverterUByte.read(buf)
}
}

override fun allocationSize(value: List<UByte>): Int {
val sizeForLength = 4
val sizeForItems = value.map { FfiConverterUByte.allocationSize(it) }.sum()
return sizeForLength + sizeForItems
}

override fun write(value: List<UByte>, buf: ByteBuffer) {
buf.putInt(value.size)
value.forEach {
FfiConverterUByte.write(it, buf)
}
}
}

fun `markdownToDocx`(`markdown`: String): List<UByte> {
return FfiConverterSequenceUByte.lift(
rustCall { _status ->
_UniFFILib.INSTANCE.uniffi_beautyxt_rs_fn_func_markdown_to_docx(
FfiConverterString.lower(`markdown`),
_status
)
})
}


fun `markdownToHtml`(`markdown`: String): String {
return FfiConverterString.lift(
rustCall() { _status ->
rustCall { _status ->
_UniFFILib.INSTANCE.uniffi_beautyxt_rs_fn_func_markdown_to_html(FfiConverterString.lower(`markdown`),_status)
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ package dev.soupslurpr.beautyxt.settings

import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey

/** Preference pairs, the first is the preference key, and the second is the default value. */
data class PreferencesUiState(
Expand Down Expand Up @@ -40,5 +40,13 @@ data class PreferencesUiState(
val experimentalFeatureOpenAnyFileType: Pair<Preferences.Key<Boolean>, MutableState<Boolean>> = Pair(
(booleanPreferencesKey("EXPERIMENTAL_FEATURE_OPEN_ANY_FILE_TYPE")),
mutableStateOf(false)
)
),

/** Experimental feature that shows an export option on markdown files to export to .docx.
* It does not support all markdown yet and will crash when trying to export a file with unsupported markdown.
*/
val experimentalFeatureExportMarkdownToDocx: Pair<Preferences.Key<Boolean>, MutableState<Boolean>> = Pair(
(booleanPreferencesKey("EXPERIMENTAL_FEATURE_EXPORT_MARKDOWN_TO_DOCX")),
mutableStateOf(false)
),
)
Original file line number Diff line number Diff line change
Expand Up @@ -38,23 +38,46 @@ class PreferencesViewModel(private val dataStore: DataStore<Preferences>) : View
currentState.copy(
pitchBlackBackground = Pair(
uiState.value.pitchBlackBackground.first,
mutableStateOf(settings[uiState.value.pitchBlackBackground.first] ?: uiState.value.pitchBlackBackground.second.value)
mutableStateOf(
settings[uiState.value.pitchBlackBackground.first] ?: uiState.value
.pitchBlackBackground.second.value
)
),
acceptedPrivacyPolicyAndLicense = Pair(
uiState.value.acceptedPrivacyPolicyAndLicense.first,
mutableStateOf(settings[uiState.value.acceptedPrivacyPolicyAndLicense.first] ?: uiState.value.acceptedPrivacyPolicyAndLicense.second.value)
mutableStateOf(
settings[uiState.value.acceptedPrivacyPolicyAndLicense.first] ?: uiState.value
.acceptedPrivacyPolicyAndLicense.second.value
)
),
renderMarkdown = Pair(
uiState.value.renderMarkdown.first,
mutableStateOf(settings[uiState.value.renderMarkdown.first] ?: uiState.value.renderMarkdown.second.value)
mutableStateOf(
settings[uiState.value.renderMarkdown.first] ?: uiState.value.renderMarkdown
.second.value
)
),
experimentalFeaturePreviewRenderedMarkdownInFullscreen = Pair(
uiState.value.experimentalFeaturePreviewRenderedMarkdownInFullscreen.first,
mutableStateOf(settings[uiState.value.experimentalFeaturePreviewRenderedMarkdownInFullscreen.first] ?: uiState.value.experimentalFeaturePreviewRenderedMarkdownInFullscreen.second.value)
mutableStateOf(
settings[uiState.value.experimentalFeaturePreviewRenderedMarkdownInFullscreen
.first]
?: uiState.value.experimentalFeaturePreviewRenderedMarkdownInFullscreen.second.value
)
),
experimentalFeatureOpenAnyFileType = Pair(
uiState.value.experimentalFeatureOpenAnyFileType.first,
mutableStateOf(settings[uiState.value.experimentalFeatureOpenAnyFileType.first] ?: uiState.value.experimentalFeatureOpenAnyFileType.second.value)
mutableStateOf(
settings[uiState.value.experimentalFeatureOpenAnyFileType.first] ?: uiState
.value.experimentalFeatureOpenAnyFileType.second.value
)
),
experimentalFeatureExportMarkdownToDocx = Pair(
uiState.value.experimentalFeatureExportMarkdownToDocx.first,
mutableStateOf(
settings[uiState.value.experimentalFeatureExportMarkdownToDocx.first]
?: uiState.value.experimentalFeatureExportMarkdownToDocx.second.value
)
)
)
}
Expand Down
20 changes: 19 additions & 1 deletion app/src/main/kotlin/dev/soupslurpr/beautyxt/ui/FileViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import dev.soupslurpr.beautyxt.data.FileUiState
import dev.soupslurpr.beautyxt.markdownToDocx
import dev.soupslurpr.beautyxt.markdownToHtml
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
Expand Down Expand Up @@ -198,14 +199,31 @@ class FileViewModel : ViewModel() {
} finally {

}
// TODO: Handle exceptions
// } catch (e: UnsupportedOperationException) {
// e.printStackTrace()
// } catch (e: SecurityException) {
// e.printStackTrace()
// }
// TODO: Handle more exceptions
// } catch (e: IOException) {
// e.printStackTrace()
// }
}

@OptIn(ExperimentalUnsignedTypes::class)
fun saveAsDocx(uri: Uri, context: Context) {
val docx = markdownToDocx(uiState.value.content.value)

try {
val contentResolver = context.contentResolver
contentResolver.openFileDescriptor(uri, "wt")?.use {
FileOutputStream(it.fileDescriptor).use {
it.write(docx.toUByteArray().toByteArray())
}
}
} finally {

}
// TODO: Handle exceptions
}
}

0 comments on commit 8c89d2c

Please sign in to comment.