Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 0 additions & 16 deletions .codegraph/.gitignore

This file was deleted.

143 changes: 0 additions & 143 deletions .codegraph/config.json

This file was deleted.

15 changes: 12 additions & 3 deletions .github/workflows/gradle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,22 @@ permissions:

jobs:
test:
name: ${{ matrix.name }}
strategy:
fail-fast: false
matrix:
include:
- target: jvmTest
- name: jvm
tasks: ":library:jvmTest"
os: ubuntu-latest
- target: testAndroidHostTest
- name: android
tasks: ":library:testAndroidHostTest"
os: ubuntu-latest
# The native target and the pdfium bindings are built against the mac-arm64 pdfium, so both
# legs need a macOS runner. Without this job the native source sets are never even compiled.
- name: native and pdfium
tasks: ":library:macosArm64Test :pdfium:jvmTest"
os: macos-latest
runs-on: ${{ matrix.os }}

steps:
Expand All @@ -32,7 +41,7 @@ jobs:
distribution: 'temurin'
- name: Run tests
uses: gradle/actions/setup-gradle@v4
- run: ./gradlew :library:${{ matrix.target }}
- run: ./gradlew ${{ matrix.tasks }}

lint:
runs-on: ubuntu-latest
Expand Down
25 changes: 0 additions & 25 deletions .github/workflows/publish.yml

This file was deleted.

54 changes: 37 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

[![Test](https://github.com/lemcoder/MikroMarkdown/actions/workflows/gradle.yml/badge.svg)](https://github.com/lemcoder/MikroMarkdown/actions/workflows/gradle.yml)

Kotlin Multiplatform (JVM + Android) library that converts documents to Markdown. Port of Microsoft's [MarkItDown](https://github.com/microsoft/markitdown).
Kotlin Multiplatform library that converts documents to Markdown, on the JVM, Android and Kotlin/Native. Port of
Microsoft's [MarkItDown](https://github.com/microsoft/markitdown).

## Supported formats

Expand Down Expand Up @@ -37,8 +38,11 @@ bytes ──► MimeDetector ──► DocumentConverter.parse ──► Documen
```

Converters contain no Markdown syntax, so escaping, table shaping, list indentation and spacing are
fixed once for all formats. Every converter lives in `commonMain` and runs on every target; only PDF
is platform-specific, and it lives in its own module because it needs a native library. The model is
fixed once for all formats. Every converter lives in `commonMain` and runs on every target, so the
library has one registration list rather than one per platform and one dependency set rather than a
JVM-only one on top: Tika, PDFBox and POI are gone, and what is left — kotlinx-io, Ksoup and
korlibs-compression — is multiplatform and shared by every target. Only PDF is platform-specific,
and it lives in its own module because it needs a native library. The model is
public: `mid.parse(path)` returns the `Document`, and `ConversionResult.document` exposes it
alongside the rendered Markdown.

Expand All @@ -53,12 +57,9 @@ println(compact.render(document))

## Setup

```kotlin
// build.gradle.kts
dependencies {
implementation("io.github.lemcoder:mikromarkdown:0.1.0")
}
```
Nothing is published yet: the project builds and tests from source, and no module is configured to
publish. Build it with `./gradlew build`, and depend on `:library` from a composite build (PDF adds
`:pdfium`) until a release is wired up.

## Usage

Expand All @@ -82,15 +83,25 @@ println(result.title) // nullable, extracted from document metadata

### Android

Identical — `MikroMarkdown()` is one common function, and every converter it registers is common
code.

```kotlin
import io.github.lemcoder.mikromarkdown.MikroMarkdown

// pass Context to enable PDF support
val mid = MikroMarkdown(context)
val mid = MikroMarkdown()

val result = mid.convert(file.absolutePath)
```

### PDF

PDF needs pdfium, so it ships as `:pdfium` and the caller opts in:

```kotlin
val mid = MikroMarkdown().apply { register(PdfiumConverter()) }
```

## Custom converters

Implement `DocumentConverter` and register it:
Expand All @@ -100,10 +111,8 @@ class MyConverter : DocumentConverter {
override fun accepts(bytes: ByteArray, info: StreamInfo): Boolean =
info.extension == "xyz"

override fun parse(bytes: ByteArray, info: StreamInfo): Document = document {
heading(1, "Custom")
paragraph(bytes.decodeToString())
}
override fun parse(bytes: ByteArray, info: StreamInfo): Document =
Document(blocks = listOf(Heading(1, "Custom"), Paragraph(bytes.decodeToString())))
}

val mid = MikroMarkdown()
Expand All @@ -115,7 +124,10 @@ Lower priority runs first. `PlainTextConverter` uses `10.0` so it acts as a fall

## Custom MIME detection

`MimeDetector` is a `fun interface` — pass a lambda or implement it:
`MimeDetector` is a `fun interface` — pass a lambda or implement it. The default,
`SignatureMimeDetector`, reads the leading bytes and falls back to the extension; content sniffing
for extension-less text formats is where a full MIME registry such as Apache Tika goes, as your
dependency rather than the library's:

```kotlin
val mid = MikroMarkdown(MimeDetector { path ->
Expand Down Expand Up @@ -155,7 +167,7 @@ import the renderer or each other; Markdown syntax appears only under `render/`.
every `DocumentConverter` is named `*Converter` and lives in `converters`.

*Hygiene* — no wildcard imports, no printing from library code, and no source file duplicated
between source sets.
between source sets — a rule that now has no exceptions, since every production file is common.

ktfmt-gradle only derives tasks for the common and JVM source sets, so `library/build.gradle.kts`
registers matching tasks for the Android ones.
Expand Down Expand Up @@ -206,3 +218,11 @@ python3 scripts/benchmark.py

Engines whose CLI is missing are skipped. anydoc only handles binary formats, so it sits out the
HTML/JSON/XML fixtures.

Both comparison engines are submodules pinned to the versions the figures were measured against —
anydoc v0.1.8, markitdown v0.1.6 — so a clone needs them fetched before either script has anything
to compare with:

```bash
git submodule update --init
```
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.github.lemcoder.mikromarkdown.benchmark

import io.github.lemcoder.mikromarkdown.MikroMarkdown
import io.github.lemcoder.mikromarkdown.MikroMarkdownException
import io.github.lemcoder.mikromarkdown.StreamInfo
import java.io.File
import kotlin.system.measureNanoTime
Expand All @@ -10,7 +11,7 @@ import kotlin.system.measureNanoTime
*
* The CLI's wall clock is dominated by JVM startup and class loading, which says nothing about the pipeline itself.
* This measures the stages separately on a warmed-up JVM, and separately reports the first conversion in a fresh JVM —
* the one that pays for loading POI, PDFBox and Tika.
* the one that pays for class loading.
*
* Usage: ./gradlew :benchmark:run --args="[fixtureDir] [warmup] [iterations]"
*/
Expand All @@ -30,11 +31,14 @@ fun main(args: Array<String>) {
require(files.isNotEmpty()) { "no fixtures in ${fixtures.absolutePath}" }

val first = files.first()
val coldStart = measureNanoTime { MikroMarkdown().convert(first.absolutePath) }
report("first conversion in a fresh JVM, class loading included (${first.name})", coldStart)
// The cold number is the first conversion in the process or it is nothing: a second attempt would run warm.
runCatching { measureNanoTime { MikroMarkdown().convert(first.absolutePath) } }
.onSuccess { report("first conversion in a fresh JVM, class loading included (${first.name})", it) }
.onFailure { println("no cold-start timing: ${first.name} has no registered converter") }
println()

val mikroMarkdown = MikroMarkdown()
val skipped = mutableListOf<String>()

println("Best of $iterations runs after $warmup warmup runs, milliseconds.")
println()
Expand All @@ -45,7 +49,14 @@ fun main(args: Array<String>) {
val bytes = file.readBytes()
val info = StreamInfo(extension = file.extension, filename = file.name, localPath = file.absolutePath)

repeat(warmup) { mikroMarkdown.convert(bytes, info) }
// The directory keeps fixtures no converter accepts: the office formats, and PDF until `:pdfium` is
// registered. They are named below rather than dropped, so a missing row never reads as a fast one.
try {
repeat(warmup) { mikroMarkdown.convert(bytes, info) }
} catch (e: MikroMarkdownException) {
skipped += "${file.name} (${e.message})"
continue
}

val parse = best(iterations) { mikroMarkdown.parse(bytes, info) }
val convertBytes = best(iterations) { mikroMarkdown.convert(bytes, info) }
Expand All @@ -58,6 +69,11 @@ fun main(args: Array<String>) {
"${convertBytes.ms()} | ${convertPath.ms()} |"
)
}

if (skipped.isNotEmpty()) {
println()
println("Skipped: ${skipped.joinToString()}")
}
}

private fun coldBytes(file: File) {
Expand Down
Loading
Loading