From 30f9862e96b2286aca5cc11cea869b5e5ce0f232 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Tue, 11 Aug 2026 22:57:24 +0200 Subject: [PATCH 1/2] Move vendored reference checkouts under third-party/ markitdown and anydoc are reference sources for the benchmark, not part of the build. Grouping them keeps the repository root to this project's own layout. Co-Authored-By: Claude Opus 5 (1M context) --- .gitmodules | 4 +- .../mikromarkdown/converters/CsvConverter.kt | 33 -- .../mikromarkdown/converters/DocxConverter.kt | 164 --------- .../mikromarkdown/converters/EpubConverter.kt | 132 -------- .../mikromarkdown/converters/HtmlConverter.kt | 15 - .../mikromarkdown/converters/JsonConverter.kt | 44 --- .../mikromarkdown/converters/PptxConverter.kt | 237 ------------- .../mikromarkdown/converters/XlsxConverter.kt | 63 ---- .../mikromarkdown/converters/XmlConverter.kt | 46 --- .../mikromarkdown/utils/HtmlToDocument.kt | 319 ------------------ .../mikromarkdown/converters/CsvConverter.kt | 0 .../mikromarkdown/converters/DocxConverter.kt | 0 .../mikromarkdown/converters/EpubConverter.kt | 0 .../mikromarkdown/converters/HtmlConverter.kt | 0 .../mikromarkdown/converters/JsonConverter.kt | 0 .../mikromarkdown/converters/PptxConverter.kt | 0 .../mikromarkdown/converters/XlsxConverter.kt | 0 .../mikromarkdown/converters/XmlConverter.kt | 0 .../mikromarkdown/utils/HtmlToDocument.kt | 0 anydoc => third-party/anydoc | 0 markitdown => third-party/markitdown | 0 21 files changed, 2 insertions(+), 1055 deletions(-) delete mode 100644 library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/CsvConverter.kt delete mode 100644 library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/DocxConverter.kt delete mode 100644 library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/EpubConverter.kt delete mode 100644 library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/HtmlConverter.kt delete mode 100644 library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/JsonConverter.kt delete mode 100644 library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/PptxConverter.kt delete mode 100644 library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/XlsxConverter.kt delete mode 100644 library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/XmlConverter.kt delete mode 100644 library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/utils/HtmlToDocument.kt rename library/src/{androidMain => jvmShared}/kotlin/io/github/lemcoder/mikromarkdown/converters/CsvConverter.kt (100%) rename library/src/{androidMain => jvmShared}/kotlin/io/github/lemcoder/mikromarkdown/converters/DocxConverter.kt (100%) rename library/src/{androidMain => jvmShared}/kotlin/io/github/lemcoder/mikromarkdown/converters/EpubConverter.kt (100%) rename library/src/{androidMain => jvmShared}/kotlin/io/github/lemcoder/mikromarkdown/converters/HtmlConverter.kt (100%) rename library/src/{androidMain => jvmShared}/kotlin/io/github/lemcoder/mikromarkdown/converters/JsonConverter.kt (100%) rename library/src/{androidMain => jvmShared}/kotlin/io/github/lemcoder/mikromarkdown/converters/PptxConverter.kt (100%) rename library/src/{androidMain => jvmShared}/kotlin/io/github/lemcoder/mikromarkdown/converters/XlsxConverter.kt (100%) rename library/src/{androidMain => jvmShared}/kotlin/io/github/lemcoder/mikromarkdown/converters/XmlConverter.kt (100%) rename library/src/{androidMain => jvmShared}/kotlin/io/github/lemcoder/mikromarkdown/utils/HtmlToDocument.kt (100%) rename anydoc => third-party/anydoc (100%) rename markitdown => third-party/markitdown (100%) diff --git a/.gitmodules b/.gitmodules index 1a7c4c3..7800b41 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "markitdown"] - path = markitdown + path = third-party/markitdown url = https://github.com/microsoft/markitdown [submodule "anydoc"] - path = anydoc + path = third-party/anydoc url = https://github.com/firecrawl/anydoc.git diff --git a/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/CsvConverter.kt b/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/CsvConverter.kt deleted file mode 100644 index 9f11ac2..0000000 --- a/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/CsvConverter.kt +++ /dev/null @@ -1,33 +0,0 @@ -package io.github.lemcoder.mikromarkdown.converters - -import io.github.lemcoder.mikromarkdown.DocumentConverter -import io.github.lemcoder.mikromarkdown.StreamInfo -import io.github.lemcoder.mikromarkdown.model.Document -import io.github.lemcoder.mikromarkdown.model.Table -import io.github.lemcoder.mikromarkdown.model.TableCell -import java.io.InputStreamReader -import org.apache.commons.csv.CSVFormat -import org.apache.commons.csv.CSVParser - -class CsvConverter : DocumentConverter { - override fun accepts(bytes: ByteArray, info: StreamInfo): Boolean { - return info.extension == "csv" || info.mimetype in setOf("text/csv", "application/csv") - } - - override fun parse(bytes: ByteArray, info: StreamInfo): Document { - val reader = InputStreamReader(bytes.inputStream(), Charsets.UTF_8) - val records = CSVParser(reader, CSVFormat.DEFAULT.builder().setTrim(true).build()).records - if (records.isEmpty()) return Document() - - val header = records[0].toList() - if (header.isEmpty()) return Document() - - val rows = - records.drop(1).map { record -> - // Ragged rows are padded by the renderer; only extra columns need trimming here. - List(header.size) { col -> TableCell(record.takeIf { col < it.size() }?.get(col) ?: "") } - } - - return Document(blocks = listOf(Table(header = header.map { TableCell(it) }, rows = rows))) - } -} diff --git a/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/DocxConverter.kt b/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/DocxConverter.kt deleted file mode 100644 index 79a154a..0000000 --- a/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/DocxConverter.kt +++ /dev/null @@ -1,164 +0,0 @@ -package io.github.lemcoder.mikromarkdown.converters - -import io.github.lemcoder.mikromarkdown.DocumentConverter -import io.github.lemcoder.mikromarkdown.StreamInfo -import io.github.lemcoder.mikromarkdown.model.Asset -import io.github.lemcoder.mikromarkdown.model.Block -import io.github.lemcoder.mikromarkdown.model.Document -import io.github.lemcoder.mikromarkdown.model.Heading -import io.github.lemcoder.mikromarkdown.model.Image -import io.github.lemcoder.mikromarkdown.model.Inline -import io.github.lemcoder.mikromarkdown.model.ListBlock -import io.github.lemcoder.mikromarkdown.model.ListItem -import io.github.lemcoder.mikromarkdown.model.Paragraph -import io.github.lemcoder.mikromarkdown.model.Table -import io.github.lemcoder.mikromarkdown.model.TableCell -import io.github.lemcoder.mikromarkdown.model.Text -import io.github.lemcoder.mikromarkdown.model.plainText -import io.github.lemcoder.mikromarkdown.model.styled -import java.util.Base64 -import org.apache.poi.xwpf.usermodel.XWPFDocument -import org.apache.poi.xwpf.usermodel.XWPFParagraph -import org.apache.poi.xwpf.usermodel.XWPFTable - -class DocxConverter : DocumentConverter { - override fun accepts(bytes: ByteArray, info: StreamInfo): Boolean { - return info.extension == "docx" || - info.mimetype == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - } - - override fun parse(bytes: ByteArray, info: StreamInfo): Document { - val docx = XWPFDocument(bytes.inputStream()) - try { - val blocks = mutableListOf() - val assets = mutableListOf() - var title: String? = docx.properties?.coreProperties?.title?.trim()?.ifBlank { null } - val pendingListItems = mutableListOf>>() - - fun flushList() { - if (pendingListItems.isEmpty()) return - blocks += buildNestedList(pendingListItems) - pendingListItems.clear() - } - - for (element in docx.bodyElements) { - when (element) { - is XWPFParagraph -> { - val content = paragraphInlines(element, assets) - if (content.plainText().isBlank() && content.none { it is Image }) continue - - val level = headingLevel(element.styleID) - when { - level > 0 -> { - flushList() - if (title == null) title = content.plainText().trim() - blocks += Heading(level, content) - } - - element.numID != null -> - pendingListItems += (element.numIlvl?.toInt() ?: 0).coerceAtLeast(0) to content - - else -> { - flushList() - blocks += Paragraph(content) - } - } - } - - is XWPFTable -> { - flushList() - table(element)?.let { blocks += it } - } - } - } - flushList() - - return Document(blocks = blocks, title = title, assets = assets) - } finally { - docx.close() - } - } - - private fun paragraphInlines(para: XWPFParagraph, assets: MutableList): List { - val out = mutableListOf() - for (run in para.runs) { - val pictures = run.embeddedPictures - if (pictures.isNotEmpty()) { - for (picture in pictures) { - val data = picture.pictureData - val alt = picture.description.orEmpty() - if (data == null) { - if (alt.isNotBlank()) out += Text(alt) - continue - } - val mime = data.pictureTypeEnum.contentType - val id = data.fileName ?: "image-${assets.size + 1}" - assets += Asset(id = id, mediaType = mime, bytes = data.data, name = data.fileName) - out += - Image( - alt = alt, - url = "data:$mime;base64,${Base64.getEncoder().encodeToString(data.data)}", - assetId = id, - ) - } - continue - } - - val text = run.text() ?: continue - if (text.isEmpty()) continue - if (text.isBlank()) { - out += Text(text) - continue - } - out += styled(listOf(Text(text)), bold = run.isBold, italic = run.isItalic, strike = run.isStrikeThrough) - } - return out - } - - /** Rebuilds Word's flat numbering levels into nested list blocks. */ - private fun buildNestedList(items: List>>): ListBlock { - var index = 0 - - fun build(level: Int): List { - val result = mutableListOf() - while (index < items.size) { - val (itemLevel, content) = items[index] - when { - itemLevel < level -> break - itemLevel == level -> { - index++ - val children = - if (index < items.size && items[index].first > level) { - listOf(ListBlock(ordered = false, items = build(items[index].first))) - } else { - emptyList() - } - result += ListItem(listOf(Paragraph(content)) + children) - } - // A deeper first item without a parent: promote it to this level. - else -> result += ListItem(listOf(ListBlock(ordered = false, items = build(itemLevel)))) - } - } - return result - } - - return ListBlock(ordered = false, items = build(items.minOf { it.first })) - } - - private fun table(table: XWPFTable): Table? { - val rows = table.rows - if (rows.isEmpty()) return null - val header = rows[0].tableCells.map { TableCell(it.text.trim()) } - val body = rows.drop(1).map { row -> row.tableCells.map { TableCell(it.text.trim()) } } - return Table(header = header, rows = body) - } - - private fun headingLevel(style: String?): Int { - val s = style?.replace("\\s+".toRegex(), "") ?: return 0 - if (s.startsWith("Heading", ignoreCase = true)) { - return s.drop(7).toIntOrNull()?.coerceIn(1, 6) ?: 0 - } - // OOXML numeric style IDs 1-6 map directly to heading levels - return s.toIntOrNull()?.takeIf { it in 1..6 } ?: 0 - } -} diff --git a/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/EpubConverter.kt b/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/EpubConverter.kt deleted file mode 100644 index 2542a64..0000000 --- a/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/EpubConverter.kt +++ /dev/null @@ -1,132 +0,0 @@ -package io.github.lemcoder.mikromarkdown.converters - -import io.github.lemcoder.mikromarkdown.DocumentConverter -import io.github.lemcoder.mikromarkdown.StreamInfo -import io.github.lemcoder.mikromarkdown.model.Block -import io.github.lemcoder.mikromarkdown.model.Document -import io.github.lemcoder.mikromarkdown.model.Paragraph -import io.github.lemcoder.mikromarkdown.model.Strong -import io.github.lemcoder.mikromarkdown.model.Text -import io.github.lemcoder.mikromarkdown.utils.HtmlToDocument -import java.io.StringReader -import java.util.zip.ZipInputStream -import javax.xml.parsers.DocumentBuilderFactory -import org.w3c.dom.Element -import org.xml.sax.InputSource - -class EpubConverter : DocumentConverter { - private val metaFields = - listOf( - "title" to "Title", - "creator" to "Authors", - "language" to "Language", - "description" to "Description", - "identifier" to "Identifier", - ) - - override fun accepts(bytes: ByteArray, info: StreamInfo): Boolean { - return info.extension == "epub" || info.mimetype == "application/epub+zip" - } - - override fun parse(bytes: ByteArray, info: StreamInfo): Document { - val entries = readZip(bytes) - - val containerXml = entries["META-INF/container.xml"] ?: return Document() - val opfPath = parseOpfPath(containerXml) ?: return Document() - val opfBytes = entries[opfPath] ?: entries[opfPath.removePrefix("/")] ?: return Document() - val opfDir = opfPath.substringBeforeLast("/", "") - - val (manifest, spine, metadata) = parseOpf(opfBytes) - - val blocks = mutableListOf() - var title: String? = metadata["title"] - - for ((key, label) in metaFields) { - val value = metadata[key] ?: continue - blocks += Paragraph(listOf(Strong(listOf(Text("$label:"))), Text(" $value"))) - } - - for (idref in spine) { - val href = manifest[idref] ?: continue - val fullPath = if (opfDir.isEmpty()) href else "$opfDir/$href" - val htmlBytes = entries[fullPath] ?: entries[fullPath.removePrefix("/")] ?: continue - val chapter = HtmlToDocument.parse(htmlBytes.toString(Charsets.UTF_8)) - if (title == null) title = chapter.title - blocks += chapter.blocks - } - - return Document(blocks = blocks, title = title, metadata = metadata) - } - - private fun readZip(bytes: ByteArray): Map { - val entries = mutableMapOf() - ZipInputStream(bytes.inputStream()).use { zip -> - var entry = zip.nextEntry - while (entry != null) { - if (!entry.isDirectory) { - entries[entry.name] = zip.readBytes() - } - zip.closeEntry() - entry = zip.nextEntry - } - } - return entries - } - - private fun parseOpfPath(containerXml: ByteArray): String? { - val doc = parseXml(containerXml) ?: return null - val rootfiles = doc.getElementsByTagName("rootfile") - if (rootfiles.length == 0) return null - return (rootfiles.item(0) as? Element)?.getAttribute("full-path") - } - - private fun parseOpf(opfBytes: ByteArray): Triple, List, Map> { - val doc = parseXml(opfBytes) ?: return Triple(emptyMap(), emptyList(), emptyMap()) - - val metadata = mutableMapOf() - for (tag in listOf("dc:title", "dc:creator", "dc:language", "dc:description", "dc:identifier")) { - val nodes = doc.getElementsByTagName(tag) - if (nodes.length > 0) { - val text = nodes.item(0).textContent?.trim() - if (!text.isNullOrEmpty()) { - metadata[tag.removePrefix("dc:")] = text - } - } - } - - val manifest = mutableMapOf() - val manifestItems = doc.getElementsByTagName("item") - for (i in 0 until manifestItems.length) { - val item = manifestItems.item(i) as? Element ?: continue - val id = item.getAttribute("id") - val href = item.getAttribute("href") - val mediaType = item.getAttribute("media-type") - if (id.isNotEmpty() && href.isNotEmpty() && isReadableChapter(mediaType)) { - manifest[id] = href - } - } - - val spine = mutableListOf() - val itemrefs = doc.getElementsByTagName("itemref") - for (i in 0 until itemrefs.length) { - val itemref = itemrefs.item(i) as? Element ?: continue - val idref = itemref.getAttribute("idref") - if (idref.isNotEmpty()) spine.add(idref) - } - - return Triple(manifest, spine, metadata) - } - - /** Only the spine's (X)HTML documents carry text; images and styles are skipped. */ - private fun isReadableChapter(mediaType: String): Boolean = mediaType.contains("html") - - private fun parseXml(bytes: ByteArray) = - try { - val factory = DocumentBuilderFactory.newInstance() - factory.isNamespaceAware = false - factory.isExpandEntityReferences = false - factory.newDocumentBuilder().parse(InputSource(StringReader(bytes.toString(Charsets.UTF_8)))) - } catch (_: Exception) { - null - } -} diff --git a/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/HtmlConverter.kt b/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/HtmlConverter.kt deleted file mode 100644 index b716b5d..0000000 --- a/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/HtmlConverter.kt +++ /dev/null @@ -1,15 +0,0 @@ -package io.github.lemcoder.mikromarkdown.converters - -import io.github.lemcoder.mikromarkdown.DocumentConverter -import io.github.lemcoder.mikromarkdown.StreamInfo -import io.github.lemcoder.mikromarkdown.model.Document -import io.github.lemcoder.mikromarkdown.utils.HtmlToDocument - -class HtmlConverter : DocumentConverter { - override fun accepts(bytes: ByteArray, info: StreamInfo): Boolean { - return info.extension in setOf("html", "htm") || info.mimetype in setOf("text/html", "application/xhtml+xml") - } - - override fun parse(bytes: ByteArray, info: StreamInfo): Document = - HtmlToDocument.parse(bytes.toString(Charsets.UTF_8), info.localPath.orEmpty()) -} diff --git a/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/JsonConverter.kt b/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/JsonConverter.kt deleted file mode 100644 index 9b4dbf4..0000000 --- a/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/JsonConverter.kt +++ /dev/null @@ -1,44 +0,0 @@ -package io.github.lemcoder.mikromarkdown.converters - -import com.fasterxml.jackson.core.JsonGenerator -import com.fasterxml.jackson.core.util.DefaultIndenter -import com.fasterxml.jackson.core.util.DefaultPrettyPrinter -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.kotlin.registerKotlinModule -import io.github.lemcoder.mikromarkdown.DocumentConverter -import io.github.lemcoder.mikromarkdown.StreamInfo -import io.github.lemcoder.mikromarkdown.model.CodeBlock -import io.github.lemcoder.mikromarkdown.model.Document - -class JsonConverter : DocumentConverter { - private val writer = - ObjectMapper() - .apply { registerKotlinModule() } - .writer( - object : DefaultPrettyPrinter() { - init { - indentArraysWith(DefaultIndenter(" ", "\n")) - indentObjectsWith(DefaultIndenter(" ", "\n")) - } - - override fun createInstance() = this - - override fun writeObjectFieldValueSeparator(g: JsonGenerator) = g.writeRaw(": ") - } - ) - - override fun accepts(bytes: ByteArray, info: StreamInfo): Boolean { - return info.extension == "json" || info.mimetype in setOf("application/json", "text/json") - } - - override fun parse(bytes: ByteArray, info: StreamInfo): Document { - val json = bytes.toString(Charsets.UTF_8) - val pretty = - try { - writer.writeValueAsString(ObjectMapper().readTree(json)) - } catch (_: Exception) { - json - } - return Document(blocks = listOf(CodeBlock(pretty, "json"))) - } -} diff --git a/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/PptxConverter.kt b/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/PptxConverter.kt deleted file mode 100644 index 78578a7..0000000 --- a/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/PptxConverter.kt +++ /dev/null @@ -1,237 +0,0 @@ -package io.github.lemcoder.mikromarkdown.converters - -import io.github.lemcoder.mikromarkdown.DocumentConverter -import io.github.lemcoder.mikromarkdown.StreamInfo -import io.github.lemcoder.mikromarkdown.model.Block -import io.github.lemcoder.mikromarkdown.model.Document -import io.github.lemcoder.mikromarkdown.model.Heading -import io.github.lemcoder.mikromarkdown.model.HtmlComment -import io.github.lemcoder.mikromarkdown.model.Image -import io.github.lemcoder.mikromarkdown.model.ListBlock -import io.github.lemcoder.mikromarkdown.model.ListItem -import io.github.lemcoder.mikromarkdown.model.Paragraph -import io.github.lemcoder.mikromarkdown.model.Table -import io.github.lemcoder.mikromarkdown.model.TableCell -import io.github.lemcoder.mikromarkdown.model.Text -import org.apache.poi.sl.usermodel.Placeholder -import org.apache.poi.sl.usermodel.Shape -import org.apache.poi.xslf.usermodel.XMLSlideShow -import org.apache.poi.xslf.usermodel.XSLFChart -import org.apache.poi.xslf.usermodel.XSLFGraphicFrame -import org.apache.poi.xslf.usermodel.XSLFGroupShape -import org.apache.poi.xslf.usermodel.XSLFPictureShape -import org.apache.poi.xslf.usermodel.XSLFSimpleShape -import org.apache.poi.xslf.usermodel.XSLFTable -import org.apache.poi.xslf.usermodel.XSLFTextShape -import org.openxmlformats.schemas.drawingml.x2006.chart.CTAreaSer -import org.openxmlformats.schemas.drawingml.x2006.chart.CTAxDataSource -import org.openxmlformats.schemas.drawingml.x2006.chart.CTBarSer -import org.openxmlformats.schemas.drawingml.x2006.chart.CTLineSer -import org.openxmlformats.schemas.drawingml.x2006.chart.CTNumDataSource -import org.openxmlformats.schemas.drawingml.x2006.chart.CTPieSer -import org.openxmlformats.schemas.drawingml.x2006.chart.CTScatterSer -import org.openxmlformats.schemas.drawingml.x2006.chart.CTSerTx -import org.openxmlformats.schemas.presentationml.x2006.main.CTPicture - -class PptxConverter : DocumentConverter { - override fun accepts(bytes: ByteArray, info: StreamInfo): Boolean { - return info.extension == "pptx" || - info.mimetype == "application/vnd.openxmlformats-officedocument.presentationml.presentation" - } - - override fun parse(bytes: ByteArray, info: StreamInfo): Document { - val slideShow = XMLSlideShow(bytes.inputStream()) - try { - val blocks = mutableListOf() - var title: String? = null - - for ((index, slide) in slideShow.slides.withIndex()) { - blocks += HtmlComment("Slide number: ${index + 1}") - blocks += shapeBlocks(slide.shapes) { if (index == 0 && title == null) title = it } - - val notes = - slide.notes - ?.shapes - ?.filterIsInstance() - ?.filter { (it as? XSLFSimpleShape)?.placeholder != Placeholder.SLIDE_IMAGE } - ?.joinToString("\n") { it.text } - ?.trim() - .orEmpty() - if (notes.isNotBlank()) { - blocks += Heading(3, listOf(Text("Notes:"))) - blocks += notes.lines().filter { it.isNotBlank() }.map { Paragraph(listOf(Text(it.trim()))) } - } - } - - return Document(blocks = blocks, title = title) - } finally { - slideShow.close() - } - } - - private fun shapeBlocks(shapes: Iterable>, onTitle: (String) -> Unit): List { - val blocks = mutableListOf() - for (shape in shapes) { - when { - shape is XSLFGroupShape -> blocks += shapeBlocks(shape.shapes, onTitle) - - shape is XSLFTextShape -> { - val text = shape.text.trim() - if (text.isBlank()) continue - - val placeholder = (shape as? XSLFSimpleShape)?.placeholder - if (placeholder == Placeholder.TITLE || placeholder == Placeholder.CENTERED_TITLE) { - blocks += Heading(1, listOf(Text(text))) - onTitle(text) - continue - } - - // Consecutive bullet paragraphs become one list; plain ones stay paragraphs. - val bullets = mutableListOf() - fun flushBullets() { - if (bullets.isEmpty()) return - blocks += ListBlock(ordered = false, items = bullets.toList()) - bullets.clear() - } - for (para in shape.textParagraphs) { - val paraText = para.text.trim() - if (paraText.isBlank()) continue - if (para.isBullet) { - bullets += ListItem(listOf(Paragraph(listOf(Text(paraText))))) - } else { - flushBullets() - blocks += Paragraph(listOf(Text(paraText))) - } - } - flushBullets() - } - - shape is XSLFPictureShape -> { - val description = (shape.xmlObject as? CTPicture)?.nvPicPr?.cNvPr?.descr.orEmpty() - val alt = description.ifBlank { shape.shapeName } - val filename = shape.shapeName.replace(Regex("\\W"), "") + ".jpg" - blocks += Paragraph(listOf(Image(alt, filename))) - } - - shape is XSLFGraphicFrame && shape.hasChart() -> blocks += chartBlocks(shape.chart) - - shape is XSLFTable -> table(shape)?.let { blocks += it } - } - } - return blocks - } - - private fun chartBlocks(chart: XSLFChart): List { - val blocks = mutableListOf() - blocks += Heading(3, listOf(Text(listOfNotNull("Chart", chartTitle(chart)).joinToString(": ")))) - - val series = - try { - seriesOf(chart) - } catch (_: Exception) { - blocks += Paragraph(listOf(Text("[unsupported chart]"))) - return blocks - } - if (series.isEmpty()) return blocks - - val rowCount = series.maxOf { it.categories.size } - blocks += - Table( - header = (listOf("Category") + series.map { it.name }).map { TableCell(it) }, - rows = - (0 until rowCount).map { row -> - val category = series.first().categories.getOrElse(row) { "" } - (listOf(category) + series.map { it.values.getOrElse(row) { "" } }).map { TableCell(it) } - }, - ) - return blocks - } - - private fun chartTitle(chart: XSLFChart): String? = - try { - val ctChart = chart.ctChart - if (!ctChart.isSetTitle) { - null - } else { - val tx = ctChart.title?.tx - when { - tx?.isSetRich == true -> - tx.rich.pList.flatMap { p -> p.rList.map { r -> r.t.orEmpty() } }.joinToString("") - tx?.isSetStrRef == true -> tx.strRef?.strCache?.ptList?.firstOrNull()?.v - else -> null - }?.ifBlank { null } - } - } catch (_: Exception) { - null - } - - private data class Series(val name: String, val categories: List, val values: List) - - private fun seriesOf(chart: XSLFChart): List { - val plot = chart.ctChart.plotArea - // bar/bar3D, line/line3D and area/area3D each share one generated series type. - val all = - plot.barChartList.flatMap { it.serList.map { s -> s.toSeries() } } + - plot.bar3DChartList.flatMap { it.serList.map { s -> s.toSeries() } } + - plot.lineChartList.flatMap { it.serList.map { s -> s.toSeries() } } + - plot.line3DChartList.flatMap { it.serList.map { s -> s.toSeries() } } + - plot.areaChartList.flatMap { it.serList.map { s -> s.toSeries() } } + - plot.area3DChartList.flatMap { it.serList.map { s -> s.toSeries() } } + - plot.scatterChartList.flatMap { it.serList.map { s -> s.toSeries() } } + - plot.pieChartList.flatMap { it.serList.map { s -> s.toSeries() } } - return all.filter { it.categories.isNotEmpty() || it.values.isNotEmpty() } - } - - private fun CTBarSer.toSeries() = - series(if (isSetTx) tx else null, if (isSetCat) cat else null, if (isSetVal) `val` else null) - - private fun CTLineSer.toSeries() = - series(if (isSetTx) tx else null, if (isSetCat) cat else null, if (isSetVal) `val` else null) - - private fun CTAreaSer.toSeries() = - series(if (isSetTx) tx else null, if (isSetCat) cat else null, if (isSetVal) `val` else null) - - private fun CTPieSer.toSeries() = - series(if (isSetTx) tx else null, if (isSetCat) cat else null, if (isSetVal) `val` else null) - - private fun CTScatterSer.toSeries() = - series(if (isSetTx) tx else null, if (isSetXVal) xVal else null, if (isSetYVal) yVal else null) - - private fun series(tx: CTSerTx?, categories: CTAxDataSource?, values: CTNumDataSource?) = - Series(seriesName(tx), categoryValues(categories), numericValues(values)) - - private fun categoryValues(cat: CTAxDataSource?): List = - when { - cat == null -> emptyList() - cat.isSetStrRef -> cat.strRef?.strCache?.ptList?.sortedBy { it.idx }?.map { it.v } ?: emptyList() - cat.isSetNumRef -> cat.numRef?.numCache?.ptList?.sortedBy { it.idx }?.map { it.v.orEmpty() } ?: emptyList() - cat.isSetNumLit -> cat.numLit?.ptList?.sortedBy { it.idx }?.map { it.v.orEmpty() } ?: emptyList() - cat.isSetStrLit -> cat.strLit?.ptList?.sortedBy { it.idx }?.map { it.v } ?: emptyList() - else -> emptyList() - } - - private fun numericValues(v: CTNumDataSource?): List = - when { - v == null -> emptyList() - v.isSetNumRef -> v.numRef?.numCache?.ptList?.sortedBy { it.idx }?.map { it.v.orEmpty() } ?: emptyList() - v.isSetNumLit -> v.numLit?.ptList?.sortedBy { it.idx }?.map { it.v.orEmpty() } ?: emptyList() - else -> emptyList() - } - - private fun seriesName(tx: CTSerTx?): String = - when { - tx == null -> "" - tx.isSetV -> tx.v - tx.isSetStrRef -> tx.strRef?.strCache?.ptList?.firstOrNull()?.v.orEmpty() - else -> "" - } - - private fun table(table: XSLFTable): Table? { - val rows = table.rows - if (rows.isEmpty()) return null - return Table( - header = rows[0].cells.map { TableCell(it.text.trim()) }, - rows = rows.drop(1).map { row -> row.cells.map { TableCell(it.text.trim()) } }, - ) - } -} diff --git a/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/XlsxConverter.kt b/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/XlsxConverter.kt deleted file mode 100644 index e601f5c..0000000 --- a/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/XlsxConverter.kt +++ /dev/null @@ -1,63 +0,0 @@ -package io.github.lemcoder.mikromarkdown.converters - -import io.github.lemcoder.mikromarkdown.DocumentConverter -import io.github.lemcoder.mikromarkdown.StreamInfo -import io.github.lemcoder.mikromarkdown.model.Block -import io.github.lemcoder.mikromarkdown.model.Document -import io.github.lemcoder.mikromarkdown.model.Heading -import io.github.lemcoder.mikromarkdown.model.Table -import io.github.lemcoder.mikromarkdown.model.TableCell -import io.github.lemcoder.mikromarkdown.model.Text -import kotlin.math.floor -import org.apache.poi.ss.usermodel.Cell -import org.apache.poi.ss.usermodel.CellType -import org.apache.poi.ss.usermodel.DataFormatter -import org.apache.poi.xssf.usermodel.XSSFWorkbook - -class XlsxConverter : DocumentConverter { - private val formatter = DataFormatter() - - override fun accepts(bytes: ByteArray, info: StreamInfo): Boolean { - return info.extension == "xlsx" || - info.mimetype == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - } - - override fun parse(bytes: ByteArray, info: StreamInfo): Document { - val workbook = XSSFWorkbook(bytes.inputStream()) - try { - val blocks = mutableListOf() - - for (sheet in workbook) { - val rows = sheet.toList() - if (rows.isEmpty()) continue - - val columns = rows.maxOf { it.lastCellNum.toInt().coerceAtLeast(0) } - if (columns == 0) continue - - blocks += Heading(2, listOf(Text(sheet.sheetName))) - blocks += - Table( - header = (0 until columns).map { TableCell(cellValue(rows[0].getCell(it))) }, - rows = - rows.drop(1).map { row -> (0 until columns).map { TableCell(cellValue(row.getCell(it))) } }, - ) - } - - return Document(blocks = blocks) - } finally { - workbook.close() - } - } - - private fun cellValue(cell: Cell?): String { - if (cell == null) return "" - return when (cell.cellType) { - CellType.NUMERIC -> { - val v = cell.numericCellValue - if (v == floor(v) && !v.isInfinite()) v.toLong().toString() else formatter.formatCellValue(cell) - } - CellType.BLANK -> "" - else -> formatter.formatCellValue(cell).trim() - } - } -} diff --git a/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/XmlConverter.kt b/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/XmlConverter.kt deleted file mode 100644 index 7d3e6ac..0000000 --- a/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/XmlConverter.kt +++ /dev/null @@ -1,46 +0,0 @@ -package io.github.lemcoder.mikromarkdown.converters - -import io.github.lemcoder.mikromarkdown.DocumentConverter -import io.github.lemcoder.mikromarkdown.StreamInfo -import io.github.lemcoder.mikromarkdown.model.CodeBlock -import io.github.lemcoder.mikromarkdown.model.Document -import java.io.StringReader -import java.io.StringWriter -import javax.xml.parsers.DocumentBuilderFactory -import javax.xml.transform.OutputKeys -import javax.xml.transform.TransformerFactory -import javax.xml.transform.dom.DOMSource -import javax.xml.transform.stream.StreamResult -import org.xml.sax.InputSource - -class XmlConverter : DocumentConverter { - override fun accepts(bytes: ByteArray, info: StreamInfo): Boolean { - return info.extension == "xml" || info.mimetype in setOf("text/xml", "application/xml") - } - - override fun parse(bytes: ByteArray, info: StreamInfo): Document { - val pretty = prettyPrint(bytes.toString(Charsets.UTF_8)) - return Document(blocks = listOf(CodeBlock(pretty, "xml"))) - } - - private fun prettyPrint(xml: String): String = - try { - val factory = DocumentBuilderFactory.newInstance() - factory.isNamespaceAware = true - factory.isIgnoringElementContentWhitespace = true - val doc = factory.newDocumentBuilder().parse(InputSource(StringReader(xml))) - - val tf = TransformerFactory.newInstance() - tf.setAttribute("indent-number", 2) - val transformer = tf.newTransformer() - transformer.setOutputProperty(OutputKeys.INDENT, "yes") - transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes") - transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2") - - val writer = StringWriter() - transformer.transform(DOMSource(doc), StreamResult(writer)) - writer.toString().trim().lines().filter { it.isNotBlank() }.joinToString("\n") - } catch (_: Exception) { - xml - } -} diff --git a/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/utils/HtmlToDocument.kt b/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/utils/HtmlToDocument.kt deleted file mode 100644 index 510e036..0000000 --- a/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/utils/HtmlToDocument.kt +++ /dev/null @@ -1,319 +0,0 @@ -package io.github.lemcoder.mikromarkdown.utils - -import io.github.lemcoder.mikromarkdown.model.Block -import io.github.lemcoder.mikromarkdown.model.BlockQuote -import io.github.lemcoder.mikromarkdown.model.CodeBlock -import io.github.lemcoder.mikromarkdown.model.CodeSpan -import io.github.lemcoder.mikromarkdown.model.Document -import io.github.lemcoder.mikromarkdown.model.Emphasis -import io.github.lemcoder.mikromarkdown.model.Heading -import io.github.lemcoder.mikromarkdown.model.Image -import io.github.lemcoder.mikromarkdown.model.Inline -import io.github.lemcoder.mikromarkdown.model.LineBreak -import io.github.lemcoder.mikromarkdown.model.Link -import io.github.lemcoder.mikromarkdown.model.ListBlock -import io.github.lemcoder.mikromarkdown.model.ListItem -import io.github.lemcoder.mikromarkdown.model.Paragraph -import io.github.lemcoder.mikromarkdown.model.Strikethrough -import io.github.lemcoder.mikromarkdown.model.Strong -import io.github.lemcoder.mikromarkdown.model.Table -import io.github.lemcoder.mikromarkdown.model.TableCell -import io.github.lemcoder.mikromarkdown.model.Text -import io.github.lemcoder.mikromarkdown.model.ThematicBreak -import io.github.lemcoder.mikromarkdown.model.plainText -import org.jsoup.Jsoup -import org.jsoup.nodes.Element -import org.jsoup.nodes.Node -import org.jsoup.nodes.TextNode - -/** - * Walks an HTML DOM into the shared document model. - * - * Replaces the previous HTML → Markdown string conversion: tables, lists and inline emphasis become model nodes, so the - * Markdown renderer owns all syntax decisions. - */ -object HtmlToDocument { - - private val DROPPED_TAGS = - setOf( - "script", - "style", - "noscript", - "template", - "button", - "svg", - "iframe", - "form", - "input", - "select", - ) - - private val HEADINGS = mapOf("h1" to 1, "h2" to 2, "h3" to 3, "h4" to 4, "h5" to 5, "h6" to 6) - - /** Tags that only group other content; their children are lifted into the parent block flow. */ - private val CONTAINERS = - setOf( - "div", - "section", - "article", - "main", - "header", - "footer", - "aside", - "nav", - "body", - "html", - "figure", - "details", - "summary", - "fieldset", - "center", - "hgroup", - "picture", - "colgroup", - ) - - fun parse(html: String, baseUri: String = ""): Document { - val doc = Jsoup.parse(html, baseUri) - doc.select(DROPPED_TAGS.joinToString(", ")).remove() - val title = doc.title().ifBlank { null } - val root = doc.body() ?: doc - val blocks = blocks(root) - return Document(blocks = blocks, title = title ?: blocks.headingTitle()) - } - - private fun List.headingTitle(): String? = - (firstOrNull { it is Heading } as? Heading)?.content?.plainText()?.trim()?.ifBlank { null } - - /** Converts an element's children into blocks, flushing runs of inline content into paragraphs. */ - private fun blocks(parent: Element): List { - val out = mutableListOf() - val pending = mutableListOf() - - fun flush() { - val trimmed = pending.trimEdges() - if (trimmed.isNotEmpty()) out += Paragraph(trimmed) - pending.clear() - } - - for (node in parent.childNodes()) { - when { - node is TextNode -> { - val text = node.normalizedText() - if (text.isNotEmpty()) pending += Text(text) - } - - node is Element && node.isBlockLevel() -> { - flush() - out += blockFor(node) - } - - node is Element && node.tagName() == "br" -> pending += LineBreak - - // Inline elements (links, emphasis, …) keep their wrapper — appendNode, not children. - node is Element -> pending.appendNode(node) - } - } - flush() - return out - } - - private fun Element.isBlockLevel(): Boolean = - when (tagName()) { - in HEADINGS, - "p", - "ul", - "ol", - "table", - "pre", - "blockquote", - "hr", - "dl", - "li", - "figcaption" -> true - in CONTAINERS -> true - else -> false - } - - private fun blockFor(element: Element): List = - when (val tag = element.tagName()) { - in HEADINGS -> - listOfNotNull( - inlines(element) - .trimEdges() - .takeIf { it.isNotEmpty() } - ?.let { Heading(HEADINGS.getValue(tag), it, element.id().ifBlank { null }) } - ) - - "p" -> listOfNotNull(inlines(element).trimEdges().takeIf { it.isNotEmpty() }?.let { Paragraph(it) }) - "ul", - "ol" -> listBlock(element) - "table" -> table(element) - "pre" -> listOf(codeBlock(element)) - "blockquote" -> blocks(element).takeIf { it.isNotEmpty() }?.let { listOf(BlockQuote(it)) } ?: emptyList() - "hr" -> listOf(ThematicBreak) - "dl" -> definitionList(element) - "figcaption" -> - listOfNotNull( - inlines(element).trimEdges().takeIf { it.isNotEmpty() }?.let { Paragraph(listOf(Emphasis(it))) } - ) - // Containers and stray
  • outside a list contribute their children directly. - else -> blocks(element) - } - - private fun listBlock(element: Element): List { - val ordered = element.tagName() == "ol" - val start = element.attr("start").toIntOrNull() ?: 1 - val items = - element - .children() - .filter { it.tagName() == "li" } - .map { li -> ListItem(blocks = blocks(li)) } - .filter { it.blocks.isNotEmpty() } - if (items.isEmpty()) return emptyList() - return listOf(ListBlock(ordered = ordered, items = items, start = start)) - } - - private fun codeBlock(element: Element): Block { - val code = element.selectFirst("code") ?: element - val language = - code.classNames().firstOrNull { it.startsWith("language-") || it.startsWith("lang-") }?.substringAfter('-') - return CodeBlock(code.wholeText().trimEnd(), language) - } - - private fun definitionList(element: Element): List { - val out = mutableListOf() - for (child in element.children()) { - val content = inlines(child).trimEdges() - if (content.isEmpty()) continue - when (child.tagName()) { - "dt" -> out += Paragraph(listOf(Strong(content))) - "dd" -> out += Paragraph(content) - } - } - return out - } - - private fun table(element: Element): List { - val caption = element.selectFirst("caption")?.let { inlines(it).trimEdges() } ?: emptyList() - val rows = element.select("tr").filter { it.parentTable() === element } - - var header: List = emptyList() - val body = mutableListOf>() - - for ((index, tr) in rows.withIndex()) { - val cells = - tr.children() - .filter { it.tagName() == "th" || it.tagName() == "td" } - .map { cell -> - TableCell( - content = inlines(cell).trimEdges(), - colSpan = cell.attr("colspan").toIntOrNull()?.coerceIn(1, 100) ?: 1, - rowSpan = cell.attr("rowspan").toIntOrNull()?.coerceIn(1, 100) ?: 1, - ) - } - if (cells.isEmpty()) continue - - val isHeaderRow = - index == 0 && - tr.children().all { it.tagName() == "th" } && - (tr.parent()?.tagName() == "thead" || header.isEmpty()) - - if (isHeaderRow && header.isEmpty()) header = cells else body += cells - } - - if (header.isEmpty() && body.isEmpty()) return emptyList() - return listOf(Table(header = header, rows = body, caption = caption)) - } - - /** The nearest enclosing table, so nested tables do not steal each other's rows. */ - private fun Element.parentTable(): Element? = parents().firstOrNull { it.tagName() == "table" } - - private fun inlines(element: Element): List { - val out = mutableListOf() - for (node in element.childNodes()) out.appendNode(node) - return out - } - - private fun MutableList.appendNode(node: Node) { - when (node) { - is TextNode -> { - val text = node.normalizedText() - if (text.isNotEmpty()) add(Text(text)) - } - - is Element -> - when (node.tagName()) { - "br" -> add(LineBreak) - "img" -> { - val src = node.attr("abs:src").ifBlank { node.attr("src") } - val alt = node.attr("alt") - if (src.isNotBlank()) add(Image(alt, src, node.attr("title").ifBlank { null })) - } - - "a" -> { - val href = node.attr("abs:href").ifBlank { node.attr("href") } - val content = inlines(node).trimEdges() - when { - content.isEmpty() -> Unit - href.isBlank() || href.startsWith("javascript:") -> addAll(content) - else -> add(Link(content, href, node.attr("title").ifBlank { null })) - } - } - - "strong", - "b" -> wrapped(node) { Strong(it) } - "em", - "i", - "cite", - "var" -> wrapped(node) { Emphasis(it) } - "del", - "s", - "strike" -> wrapped(node) { Strikethrough(it) } - "code", - "kbd", - "samp", - "tt" -> { - val code = node.wholeText().trim() - if (code.isNotEmpty()) add(CodeSpan(code)) - } - - // Block-level content encountered inline (e.g. a
    inside a ): keep its text. - else -> addAll(inlines(node)) - } - } - } - - private inline fun MutableList.wrapped(node: Element, wrap: (List) -> Inline) { - val content = inlines(node) - val trimmed = content.trimEdges() - if (trimmed.isEmpty()) return - if (content.startsWithSpace()) add(Text(" ")) - add(wrap(trimmed)) - if (content.endsWithSpace()) add(Text(" ")) - } - - /** HTML collapses runs of whitespace; do the same before the text reaches the model. */ - // Non-breaking spaces are not collapsible whitespace in HTML, so they survive verbatim. - private fun TextNode.normalizedText(): String = wholeText.replace(Regex("\\s+"), " ") - - private fun List.startsWithSpace(): Boolean = (firstOrNull() as? Text)?.value?.startsWith(" ") == true - - private fun List.endsWithSpace(): Boolean = (lastOrNull() as? Text)?.value?.endsWith(" ") == true - - /** Drops leading/trailing whitespace-only text so emphasis markers hug their content. */ - private fun List.trimEdges(): List { - var start = 0 - var end = size - while (start < end && this[start].isBlankText()) start++ - while (end > start && this[end - 1].isBlankText()) end-- - if (start >= end) return emptyList() - val slice = subList(start, end).toMutableList() - (slice.first() as? Text)?.let { slice[0] = Text(it.value.trimStart()) } - (slice.last() as? Text)?.let { slice[slice.lastIndex] = Text(it.value.trimEnd()) } - return slice.filter { !(it is Text && it.value.isEmpty()) } - } - - // Kotlin's trim()/isBlank() drop Unicode spacing (thin, hair, …) but keep NBSP, which is content. - private fun Inline.isBlankText(): Boolean = this is Text && value.isBlank() -} diff --git a/library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/converters/CsvConverter.kt b/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/CsvConverter.kt similarity index 100% rename from library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/converters/CsvConverter.kt rename to library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/CsvConverter.kt diff --git a/library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/converters/DocxConverter.kt b/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/DocxConverter.kt similarity index 100% rename from library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/converters/DocxConverter.kt rename to library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/DocxConverter.kt diff --git a/library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/converters/EpubConverter.kt b/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/EpubConverter.kt similarity index 100% rename from library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/converters/EpubConverter.kt rename to library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/EpubConverter.kt diff --git a/library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/converters/HtmlConverter.kt b/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/HtmlConverter.kt similarity index 100% rename from library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/converters/HtmlConverter.kt rename to library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/HtmlConverter.kt diff --git a/library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/converters/JsonConverter.kt b/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/JsonConverter.kt similarity index 100% rename from library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/converters/JsonConverter.kt rename to library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/JsonConverter.kt diff --git a/library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/converters/PptxConverter.kt b/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/PptxConverter.kt similarity index 100% rename from library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/converters/PptxConverter.kt rename to library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/PptxConverter.kt diff --git a/library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/converters/XlsxConverter.kt b/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/XlsxConverter.kt similarity index 100% rename from library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/converters/XlsxConverter.kt rename to library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/XlsxConverter.kt diff --git a/library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/converters/XmlConverter.kt b/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/XmlConverter.kt similarity index 100% rename from library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/converters/XmlConverter.kt rename to library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/XmlConverter.kt diff --git a/library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/utils/HtmlToDocument.kt b/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/utils/HtmlToDocument.kt similarity index 100% rename from library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/utils/HtmlToDocument.kt rename to library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/utils/HtmlToDocument.kt diff --git a/anydoc b/third-party/anydoc similarity index 100% rename from anydoc rename to third-party/anydoc diff --git a/markitdown b/third-party/markitdown similarity index 100% rename from markitdown rename to third-party/markitdown From 2bebf48d78ab85f962c1671d75f9f19d64d98744 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Tue, 11 Aug 2026 22:57:39 +0200 Subject: [PATCH 2/2] Tighten encapsulation and enforce it with more Konsist rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JVM and Android source sets held nine byte-identical converter files. They drifted twice during recent work, each time silently, because nothing checked them against each other. - a jvmShared source set now holds every converter that both targets run; only PdfConverter (pdfbox vs pdfbox-android) and the factory stay per-target - explicit API mode: every exported declaration states visibility and return type, which turned up helpers that were public only by omission — HtmlToDocument, plainTextBlocks and both MIME detectors are now internal - converter selection moved into a ConverterRegistry that sorts on registration and never hands out its entries, instead of a Pair list re-sorted per parse - DocumentBuilder's title is private behind titleIfAbsent()/title(), and the builders carry a @DocumentDsl marker so inline scopes cannot reach document-level methods Six new Konsist rules, each verified to fail against a planted violation: - helpers under utils are never public - the model exposes no mutable state - every DocumentConverter is named *Converter and lives in converters - converters do not import each other - no wildcard imports, no printing from library code - no production file exists in two source sets, with the two platform-specific files named explicitly, so the duplication cannot come back unnoticed Converted output is byte-identical across all eight fixtures. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 23 ++++- library/build.gradle.kts | 37 +++++--- .../mikromarkdown/MikroMarkdownFactory.kt | 4 +- .../mikromarkdown/converters/PdfConverter.kt | 2 +- .../utils/AndroidMimeDetector.kt | 2 +- .../mikromarkdown/ConversionResult.kt | 2 +- .../mikromarkdown/ConverterRegistry.kt | 22 +++++ .../mikromarkdown/DocumentConverter.kt | 6 +- .../lemcoder/mikromarkdown/Exceptions.kt | 6 +- .../lemcoder/mikromarkdown/MikroMarkdown.kt | 21 +++-- .../lemcoder/mikromarkdown/MimeDetector.kt | 4 +- .../lemcoder/mikromarkdown/StreamInfo.kt | 2 +- .../MarkdownPassthroughConverter.kt | 2 +- .../converters/PlainTextConverter.kt | 2 +- .../lemcoder/mikromarkdown/model/Document.kt | 54 +++++------ .../mikromarkdown/model/DocumentBuilder.kt | 70 +++++++------- .../mikromarkdown/render/MarkdownRenderer.kt | 18 ++-- .../mikromarkdown/utils/TextBlocks.kt | 2 +- .../mikromarkdown/MikroMarkdownFactory.kt | 4 +- .../mikromarkdown/converters/PdfConverter.kt | 2 +- .../mikromarkdown/utils/TikaMimeDetector.kt | 2 +- .../mikromarkdown/converters/CsvConverter.kt | 2 +- .../mikromarkdown/converters/DocxConverter.kt | 2 +- .../mikromarkdown/converters/EpubConverter.kt | 2 +- .../mikromarkdown/converters/HtmlConverter.kt | 2 +- .../mikromarkdown/converters/JsonConverter.kt | 2 +- .../mikromarkdown/converters/PptxConverter.kt | 2 +- .../mikromarkdown/converters/XlsxConverter.kt | 2 +- .../mikromarkdown/converters/XmlConverter.kt | 2 +- .../mikromarkdown/utils/HtmlToDocument.kt | 4 +- .../mikromarkdown/ArchitectureTest.kt | 92 ++++++++++++++++--- scripts/benchmark.py | 4 +- 32 files changed, 263 insertions(+), 140 deletions(-) create mode 100644 library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/ConverterRegistry.kt diff --git a/README.md b/README.md index 74ed13d..c3df417 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,8 @@ bytes ──► MimeDetector ──► DocumentConverter.parse ──► Documen ``` Converters contain no Markdown syntax, so escaping, table shaping, list indentation and spacing are -fixed once for all formats. The model is public: `mid.parse(path)` returns the `Document`, and +fixed once for all formats. JVM and Android share one `jvmShared` source set, so a converter exists +once rather than per target; only PDF extraction and MIME detection are platform-specific. The model is public: `mid.parse(path)` returns the `Document`, and `ConversionResult.document` exposes it alongside the rendered Markdown. ```kotlin @@ -134,11 +135,23 @@ Both extend `MikroMarkdownException`. |---|---|---| | [ktfmt](https://github.com/facebook/ktfmt) | `./gradlew ktfmtFormat` / `ktfmtCheck` | formatting (kotlinlang style, 120 columns) | | [detekt](https://detekt.dev) | `./gradlew detekt` | static analysis; overrides in `config/detekt/detekt.yml` | -| [Konsist](https://docs.konsist.lemonappdev.com) | `./gradlew :library:jvmTest --tests '*ArchitectureTest*'` | pipeline boundaries | +| [Konsist](https://docs.konsist.lemonappdev.com) | `./gradlew :library:jvmTest --tests '*ArchitectureTest*'` | pipeline boundaries and encapsulation | -`./gradlew check` runs all three. The Konsist rules encode the architecture: the model depends on -nothing, converters never import the renderer, and Markdown syntax appears only under `render/` — -so a converter cannot quietly start building Markdown strings again. +`./gradlew check` runs all three. The library also builds in Kotlin's +[explicit API mode](https://kotlinlang.org/docs/whatsnew14.html#explicit-api-mode-for-library-authors), +so every exported declaration states its visibility and return type. + +The Konsist rules in `ArchitectureTest` encode the architecture, and each one is verified to fail +against a deliberate violation: + +*Layering* — the model depends on nothing and stays free of `java.*`/`android.*`; converters never +import the renderer or each other; Markdown syntax appears only under `render/`. + +*Encapsulation* — helpers under `utils` are never public, the model exposes no mutable state, and +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 (the drift that the `jvmShared` set removed). ktfmt-gradle only derives tasks for the common and JVM source sets, so `library/build.gradle.kts` registers matching tasks for the Android ones. diff --git a/library/build.gradle.kts b/library/build.gradle.kts index ad92d04..7077d0f 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -11,6 +11,9 @@ group = "io.github.lemcoder" version = "0.1.0" kotlin { + // Public API must be spelled out: visibility and return types, no accidental exports. + explicitApi() + jvm { compilerOptions { jvmTarget = JvmTarget.JVM_21 } testRuns["test"].executionTask.configure { useJUnitPlatform() } @@ -30,21 +33,29 @@ kotlin { sourceSets { commonMain.dependencies { implementation(libs.kotlinx.io.core) } - jvmMain.dependencies { - implementation(libs.jsoup) - implementation(libs.jackson.kotlin) - implementation(libs.commons.csv) - implementation(libs.poi.ooxml) - implementation(libs.tika.core) - implementation(libs.pdfbox) + // JVM and Android run the same parsers on the same libraries; only PDF and MIME + // detection differ. Converters live here once instead of being copied per target. + val jvmShared by creating { + dependsOn(commonMain.get()) + dependencies { + implementation(libs.jsoup) + implementation(libs.jackson.kotlin) + implementation(libs.commons.csv) + implementation(libs.poi.ooxml) + } + } + + jvmMain { + dependsOn(jvmShared) + dependencies { + implementation(libs.tika.core) + implementation(libs.pdfbox) + } } - androidMain.dependencies { - implementation(libs.jsoup) - implementation(libs.jackson.kotlin) - implementation(libs.commons.csv) - implementation(libs.poi.ooxml) - implementation(libs.pdfbox.android) + androidMain { + dependsOn(jvmShared) + dependencies { implementation(libs.pdfbox.android) } } commonTest.dependencies { diff --git a/library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/MikroMarkdownFactory.kt b/library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/MikroMarkdownFactory.kt index 07b15c8..8bafa41 100644 --- a/library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/MikroMarkdownFactory.kt +++ b/library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/MikroMarkdownFactory.kt @@ -21,7 +21,7 @@ import java.io.File * * PDF support needs a [Context]: pdfbox-android loads its resources from the app's assets. */ -fun MikroMarkdown(context: Context? = null): MikroMarkdown = +public fun MikroMarkdown(context: Context? = null): MikroMarkdown = MikroMarkdown(AndroidMimeDetector).apply { register(MarkdownPassthroughConverter()) register(HtmlConverter()) @@ -39,4 +39,4 @@ fun MikroMarkdown(context: Context? = null): MikroMarkdown = register(PlainTextConverter(), priority = 10.0) } -fun MikroMarkdown.convert(file: File): ConversionResult = convert(file.absolutePath) +public fun MikroMarkdown.convert(file: File): ConversionResult = convert(file.absolutePath) diff --git a/library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/converters/PdfConverter.kt b/library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/converters/PdfConverter.kt index 9eb0647..4b13531 100644 --- a/library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/converters/PdfConverter.kt +++ b/library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/converters/PdfConverter.kt @@ -7,7 +7,7 @@ import io.github.lemcoder.mikromarkdown.StreamInfo import io.github.lemcoder.mikromarkdown.model.Document import io.github.lemcoder.mikromarkdown.utils.plainTextBlocks -class PdfConverter : DocumentConverter { +public class PdfConverter : DocumentConverter { override fun accepts(bytes: ByteArray, info: StreamInfo): Boolean { return info.extension == "pdf" || info.mimetype == "application/pdf" } diff --git a/library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/utils/AndroidMimeDetector.kt b/library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/utils/AndroidMimeDetector.kt index ab075a9..dddd0a2 100644 --- a/library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/utils/AndroidMimeDetector.kt +++ b/library/src/androidMain/kotlin/io/github/lemcoder/mikromarkdown/utils/AndroidMimeDetector.kt @@ -5,7 +5,7 @@ import io.github.lemcoder.mikromarkdown.MimeDetector import io.github.lemcoder.mikromarkdown.StreamInfo import java.io.File -object AndroidMimeDetector : MimeDetector { +internal object AndroidMimeDetector : MimeDetector { override fun detect(path: String): StreamInfo { val file = File(path) val extension = file.extension.lowercase().ifEmpty { null } diff --git a/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/ConversionResult.kt b/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/ConversionResult.kt index 1f94fff..5089c49 100644 --- a/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/ConversionResult.kt +++ b/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/ConversionResult.kt @@ -2,7 +2,7 @@ package io.github.lemcoder.mikromarkdown import io.github.lemcoder.mikromarkdown.model.Document -data class ConversionResult( +public data class ConversionResult( val markdown: String, val title: String? = null, /** The intermediate model the Markdown was rendered from. */ diff --git a/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/ConverterRegistry.kt b/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/ConverterRegistry.kt new file mode 100644 index 0000000..e841b8a --- /dev/null +++ b/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/ConverterRegistry.kt @@ -0,0 +1,22 @@ +package io.github.lemcoder.mikromarkdown + +/** + * Holds the registered converters in priority order and picks the one that accepts an input. + * + * Ordering is applied on registration rather than on every lookup, and the entry list never leaves this class, so + * callers cannot reorder or inspect the pipeline's dispatch table. + */ +internal class ConverterRegistry { + private val entries = mutableListOf() + + fun register(converter: DocumentConverter, priority: Double) { + entries += Entry(converter, priority) + entries.sortBy { it.priority } + } + + /** The first converter, in priority order, that accepts this input. */ + fun select(bytes: ByteArray, info: StreamInfo): DocumentConverter? = + entries.firstOrNull { it.converter.accepts(bytes, info) }?.converter + + private data class Entry(val converter: DocumentConverter, val priority: Double) +} diff --git a/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/DocumentConverter.kt b/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/DocumentConverter.kt index ba8cc28..0f498c0 100644 --- a/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/DocumentConverter.kt +++ b/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/DocumentConverter.kt @@ -7,8 +7,8 @@ import io.github.lemcoder.mikromarkdown.model.Document * * Converters never produce Markdown — rendering is [io.github.lemcoder.mikromarkdown.render.MarkdownRenderer]'s job. */ -interface DocumentConverter { - fun accepts(bytes: ByteArray, info: StreamInfo): Boolean +public interface DocumentConverter { + public fun accepts(bytes: ByteArray, info: StreamInfo): Boolean - fun parse(bytes: ByteArray, info: StreamInfo): Document + public fun parse(bytes: ByteArray, info: StreamInfo): Document } diff --git a/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/Exceptions.kt b/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/Exceptions.kt index 9d25f11..fe5bbf3 100644 --- a/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/Exceptions.kt +++ b/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/Exceptions.kt @@ -1,7 +1,7 @@ package io.github.lemcoder.mikromarkdown -sealed class MikroMarkdownException(message: String, cause: Throwable? = null) : Exception(message, cause) +public sealed class MikroMarkdownException(message: String, cause: Throwable? = null) : Exception(message, cause) -class UnsupportedFormatException(message: String) : MikroMarkdownException(message) +public class UnsupportedFormatException(message: String) : MikroMarkdownException(message) -class FileConversionException(message: String, cause: Throwable? = null) : MikroMarkdownException(message, cause) +public class FileConversionException(message: String, cause: Throwable? = null) : MikroMarkdownException(message, cause) diff --git a/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/MikroMarkdown.kt b/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/MikroMarkdown.kt index d6d2c40..2e10ff1 100644 --- a/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/MikroMarkdown.kt +++ b/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/MikroMarkdown.kt @@ -12,30 +12,31 @@ import kotlinx.io.readByteArray * * Rendering happens here rather than inside converters, so every format shares one serializer. */ -class MikroMarkdown( +public class MikroMarkdown( private val mimeDetector: MimeDetector, private val renderer: MarkdownRenderer = MarkdownRenderer.Default, ) { - private val converters = mutableListOf>() + private val converters = ConverterRegistry() - fun register(converter: DocumentConverter, priority: Double = 0.0) { - converters.add(converter to priority) + /** Lower priority runs first; [io.github.lemcoder.mikromarkdown.converters.PlainTextConverter] uses 10.0. */ + public fun register(converter: DocumentConverter, priority: Double = 0.0) { + converters.register(converter, priority) } - fun convert(path: String): ConversionResult = render(parse(path)) + public fun convert(path: String): ConversionResult = render(parse(path)) - fun convert(bytes: ByteArray, info: StreamInfo): ConversionResult = render(parse(bytes, info)) + public fun convert(bytes: ByteArray, info: StreamInfo): ConversionResult = render(parse(bytes, info)) /** Parses without rendering, for callers that want the document model itself. */ - fun parse(path: String): Document { + public fun parse(path: String): Document { val info = mimeDetector.detect(path) val bytes = SystemFileSystem.source(Path(path)).buffered().use { it.readByteArray() } return parse(bytes, info) } - fun parse(bytes: ByteArray, info: StreamInfo): Document { + public fun parse(bytes: ByteArray, info: StreamInfo): Document { val converter = - converters.sortedBy { it.second }.firstOrNull { (candidate, _) -> candidate.accepts(bytes, info) }?.first + converters.select(bytes, info) ?: throw UnsupportedFormatException( "No converter found for: ${info.extension ?: info.mimetype ?: "unknown"}" ) @@ -53,7 +54,7 @@ class MikroMarkdown( throw FileConversionException("Conversion failed with ${this::class.simpleName}: ${e.message}", e) } - fun render(document: Document): ConversionResult = + public fun render(document: Document): ConversionResult = ConversionResult( markdown = renderer.render(document), title = document.title, diff --git a/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/MimeDetector.kt b/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/MimeDetector.kt index 2a33c85..a1fe206 100644 --- a/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/MimeDetector.kt +++ b/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/MimeDetector.kt @@ -1,5 +1,5 @@ package io.github.lemcoder.mikromarkdown -fun interface MimeDetector { - fun detect(path: String): StreamInfo +public fun interface MimeDetector { + public fun detect(path: String): StreamInfo } diff --git a/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/StreamInfo.kt b/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/StreamInfo.kt index 02cc400..90b50fd 100644 --- a/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/StreamInfo.kt +++ b/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/StreamInfo.kt @@ -1,6 +1,6 @@ package io.github.lemcoder.mikromarkdown -data class StreamInfo( +public data class StreamInfo( val mimetype: String? = null, val extension: String? = null, val charset: String? = null, diff --git a/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/converters/MarkdownPassthroughConverter.kt b/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/converters/MarkdownPassthroughConverter.kt index 98253cc..024ebca 100644 --- a/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/converters/MarkdownPassthroughConverter.kt +++ b/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/converters/MarkdownPassthroughConverter.kt @@ -5,7 +5,7 @@ import io.github.lemcoder.mikromarkdown.StreamInfo import io.github.lemcoder.mikromarkdown.model.Document import io.github.lemcoder.mikromarkdown.model.RawBlock -class MarkdownPassthroughConverter : DocumentConverter { +public class MarkdownPassthroughConverter : DocumentConverter { override fun accepts(bytes: ByteArray, info: StreamInfo): Boolean { return info.extension in setOf("md", "markdown") || info.mimetype == "text/markdown" } diff --git a/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/converters/PlainTextConverter.kt b/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/converters/PlainTextConverter.kt index 9649640..d8a45dd 100644 --- a/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/converters/PlainTextConverter.kt +++ b/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/converters/PlainTextConverter.kt @@ -5,7 +5,7 @@ import io.github.lemcoder.mikromarkdown.StreamInfo import io.github.lemcoder.mikromarkdown.model.Document import io.github.lemcoder.mikromarkdown.model.RawBlock -class PlainTextConverter : DocumentConverter { +public class PlainTextConverter : DocumentConverter { override fun accepts(bytes: ByteArray, info: StreamInfo): Boolean { return info.extension in setOf("txt", "log", "text") || info.mimetype == "text/plain" } diff --git a/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/model/Document.kt b/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/model/Document.kt index 2f33e52..b58790a 100644 --- a/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/model/Document.kt +++ b/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/model/Document.kt @@ -6,7 +6,7 @@ package io.github.lemcoder.mikromarkdown.model * Every converter parses its input into a [Document]; a single renderer turns documents into Markdown. Output quirks * are therefore fixed once, in the renderer, rather than per format. */ -data class Document( +public data class Document( val blocks: List = emptyList(), val title: String? = null, val metadata: Map = emptyMap(), @@ -14,7 +14,7 @@ data class Document( ) /** An embedded binary resource (image, thumbnail, object) referenced by [Image.assetId]. */ -data class Asset( +public data class Asset( val id: String, val mediaType: String, val bytes: ByteArray, @@ -26,21 +26,21 @@ data class Asset( override fun hashCode(): Int = id.hashCode() } -sealed interface Block +public sealed interface Block -data class Heading( +public data class Heading( val level: Int, val content: List, val anchor: String? = null, ) : Block -data class Paragraph(val content: List) : Block +public data class Paragraph(val content: List) : Block -data class CodeBlock(val code: String, val language: String? = null) : Block +public data class CodeBlock(val code: String, val language: String? = null) : Block -data class BlockQuote(val blocks: List) : Block +public data class BlockQuote(val blocks: List) : Block -data class ListBlock( +public data class ListBlock( val ordered: Boolean, val items: List, val start: Int = 1, @@ -48,57 +48,57 @@ data class ListBlock( val loose: Boolean = false, ) : Block -data class ListItem( +public data class ListItem( val blocks: List, /** Non-null makes this a GFM task-list item. */ val checked: Boolean? = null, ) -data class Table( +public data class Table( val header: List = emptyList(), val rows: List> = emptyList(), val alignments: List = emptyList(), val caption: List = emptyList(), ) : Block -data class TableCell( +public data class TableCell( val content: List, val colSpan: Int = 1, val rowSpan: Int = 1, ) { - constructor(text: String) : this(if (text.isEmpty()) emptyList() else listOf(Text(text))) + public constructor(text: String) : this(if (text.isEmpty()) emptyList() else listOf(Text(text))) } -enum class Alignment { +public enum class Alignment { NONE, LEFT, CENTER, RIGHT, } -data object ThematicBreak : Block +public data object ThematicBreak : Block /** Rendered verbatim as an HTML comment. Used for structural markers such as slide numbers. */ -data class HtmlComment(val text: String) : Block +public data class HtmlComment(val text: String) : Block /** Escape hatch for content that is already Markdown (or must not be touched). */ -data class RawBlock(val text: String) : Block +public data class RawBlock(val text: String) : Block -sealed interface Inline +public sealed interface Inline -data class Text(val value: String) : Inline +public data class Text(val value: String) : Inline -data class Strong(val content: List) : Inline +public data class Strong(val content: List) : Inline -data class Emphasis(val content: List) : Inline +public data class Emphasis(val content: List) : Inline -data class Strikethrough(val content: List) : Inline +public data class Strikethrough(val content: List) : Inline -data class CodeSpan(val code: String) : Inline +public data class CodeSpan(val code: String) : Inline -data class Link(val content: List, val url: String, val title: String? = null) : Inline +public data class Link(val content: List, val url: String, val title: String? = null) : Inline -data class Image( +public data class Image( val alt: String, val url: String, val title: String? = null, @@ -106,13 +106,13 @@ data class Image( ) : Inline /** Hard line break inside a paragraph. */ -data object LineBreak : Inline +public data object LineBreak : Inline /** Inline content that is already Markdown/HTML and must be emitted verbatim. */ -data class RawInline(val text: String) : Inline +public data class RawInline(val text: String) : Inline /** Flattens inline content to its plain-text form (used for titles, anchors, alt text). */ -fun List.plainText(): String = buildString { appendPlain(this@plainText) } +public fun List.plainText(): String = buildString { appendPlain(this@plainText) } private fun StringBuilder.appendPlain(inlines: List) { for (inline in inlines) { diff --git a/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/model/DocumentBuilder.kt b/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/model/DocumentBuilder.kt index 4a57e40..af3684c 100644 --- a/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/model/DocumentBuilder.kt +++ b/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/model/DocumentBuilder.kt @@ -11,69 +11,76 @@ package io.github.lemcoder.mikromarkdown.model * } * ``` */ -fun document(block: DocumentBuilder.() -> Unit): Document = DocumentBuilder().apply(block).build() +/** Restricts builder receivers so an inline block cannot silently call document-level methods. */ +@DslMarker public annotation class DocumentDsl -class DocumentBuilder { +@DocumentDsl public fun document(block: DocumentBuilder.() -> Unit): Document = DocumentBuilder().apply(block).build() + +@DocumentDsl +public class DocumentBuilder { private val blocks = mutableListOf() private val assets = mutableListOf() private val metadata = mutableMapOf() - var title: String? = null + private var title: String? = null - fun add(block: Block) { + public fun add(block: Block) { blocks += block } - fun addAll(blocks: Iterable) { + public fun addAll(blocks: Iterable) { this.blocks += blocks } - fun asset(asset: Asset) { + public fun asset(asset: Asset) { assets += asset } - fun meta(key: String, value: String?) { + public fun meta(key: String, value: String?) { if (!value.isNullOrBlank()) metadata[key] = value } /** Records [text] as the document title unless one was already found. */ - fun titleIfAbsent(text: String?) { + public fun titleIfAbsent(text: String?) { if (title == null && !text.isNullOrBlank()) title = text } - fun heading(level: Int, text: String) { + /** The title recorded so far, if any. */ + public fun title(): String? = title + + public fun heading(level: Int, text: String) { if (text.isNotBlank()) add(Heading(level, listOf(Text(text)))) } - fun heading(level: Int, content: List) { + public fun heading(level: Int, content: List) { if (content.isNotEmpty()) add(Heading(level, content)) } - fun paragraph(text: String) { + public fun paragraph(text: String) { if (text.isNotBlank()) add(Paragraph(listOf(Text(text)))) } - fun paragraph(content: List) { + public fun paragraph(content: List) { if (content.isNotEmpty()) add(Paragraph(content)) } - fun code(code: String, language: String? = null) { + public fun code(code: String, language: String? = null) { add(CodeBlock(code, language)) } - fun comment(text: String) { + public fun comment(text: String) { add(HtmlComment(text)) } - fun raw(text: String) { + public fun raw(text: String) { if (text.isNotBlank()) add(RawBlock(text)) } - fun bulletList(items: List) { + public fun bulletList(items: List) { if (items.isEmpty()) return add(ListBlock(ordered = false, items = items.map { ListItem(listOf(Paragraph(listOf(Text(it))))) })) } - fun table( + public fun table( header: List, rows: List>, alignments: List = emptyList(), @@ -88,7 +95,7 @@ class DocumentBuilder { ) } - fun build(): Document = + public fun build(): Document = Document( blocks = blocks.toList(), title = title, @@ -98,52 +105,53 @@ class DocumentBuilder { } /** Builds a list of inlines without repeating `listOf(...)` wrappers in parsers. */ -fun inlines(block: InlineBuilder.() -> Unit): List = InlineBuilder().apply(block).build() +@DocumentDsl public fun inlines(block: InlineBuilder.() -> Unit): List = InlineBuilder().apply(block).build() -class InlineBuilder { +@DocumentDsl +public class InlineBuilder { private val items = mutableListOf() - fun text(value: String) { + public fun text(value: String) { if (value.isNotEmpty()) items += Text(value) } - fun strong(value: String) { + public fun strong(value: String) { if (value.isNotEmpty()) items += Strong(listOf(Text(value))) } - fun emphasis(value: String) { + public fun emphasis(value: String) { if (value.isNotEmpty()) items += Emphasis(listOf(Text(value))) } - fun code(value: String) { + public fun code(value: String) { if (value.isNotEmpty()) items += CodeSpan(value) } - fun link(text: String, url: String) { + public fun link(text: String, url: String) { items += Link(listOf(Text(text)), url) } - fun image(alt: String, url: String, assetId: String? = null) { + public fun image(alt: String, url: String, assetId: String? = null) { items += Image(alt, url, assetId = assetId) } - fun lineBreak() { + public fun lineBreak() { items += LineBreak } - operator fun plusAssign(inline: Inline) { + public operator fun plusAssign(inline: Inline) { items += inline } - operator fun plusAssign(inlines: List) { + public operator fun plusAssign(inlines: List) { items += inlines } - fun build(): List = items.toList() + public fun build(): List = items.toList() } /** Wraps [content] in the emphasis combination described by the flags. */ -fun styled(content: List, bold: Boolean, italic: Boolean, strike: Boolean = false): List { +public fun styled(content: List, bold: Boolean, italic: Boolean, strike: Boolean = false): List { var result = content if (strike) result = listOf(Strikethrough(result)) if (italic) result = listOf(Emphasis(result)) diff --git a/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/render/MarkdownRenderer.kt b/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/render/MarkdownRenderer.kt index 2285c9c..183f02d 100644 --- a/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/render/MarkdownRenderer.kt +++ b/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/render/MarkdownRenderer.kt @@ -25,7 +25,7 @@ import io.github.lemcoder.mikromarkdown.model.TableCell import io.github.lemcoder.mikromarkdown.model.Text import io.github.lemcoder.mikromarkdown.model.ThematicBreak -data class MarkdownOptions( +public data class MarkdownOptions( val bullet: Char = '-', val strongMarker: String = "**", val emphasisMarker: String = "*", @@ -43,24 +43,24 @@ data class MarkdownOptions( /** Longest image URL kept inline; longer ones (e.g. base64 data URIs) are still emitted. */ val maxInlineImageUrl: Int = Int.MAX_VALUE, ) { - companion object { - val Default = MarkdownOptions() + public companion object { + public val Default: MarkdownOptions = MarkdownOptions() } } /** Serializes a [Document] to GitHub-Flavored Markdown. The only place Markdown syntax is produced. */ -class MarkdownRenderer(private val options: MarkdownOptions = MarkdownOptions.Default) { +public class MarkdownRenderer(private val options: MarkdownOptions = MarkdownOptions.Default) { - fun render(document: Document): String { + public fun render(document: Document): String { val body = renderBlocks(document.blocks) if (!options.frontMatter || document.metadata.isEmpty()) return body val front = document.metadata.entries.joinToString("\n") { (k, v) -> "$k: ${v.replace("\n", " ")}" } return "---\n$front\n---\n\n$body".trimEnd() } - fun render(blocks: List): String = renderBlocks(blocks) + public fun render(blocks: List): String = renderBlocks(blocks) - fun renderInline(inlines: List): String = inlines(inlines, TextContext.INLINE) + public fun renderInline(inlines: List): String = inlines(inlines, TextContext.INLINE) private fun renderBlocks(blocks: List): String { val chunks = mutableListOf() @@ -379,7 +379,7 @@ class MarkdownRenderer(private val options: MarkdownOptions = MarkdownOptions.De TABLE, } - companion object { - val Default = MarkdownRenderer() + public companion object { + public val Default: MarkdownRenderer = MarkdownRenderer() } } diff --git a/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/utils/TextBlocks.kt b/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/utils/TextBlocks.kt index bdbc49d..95a46e3 100644 --- a/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/utils/TextBlocks.kt +++ b/library/src/commonMain/kotlin/io/github/lemcoder/mikromarkdown/utils/TextBlocks.kt @@ -10,7 +10,7 @@ import io.github.lemcoder.mikromarkdown.model.Text * Blank lines separate paragraphs; soft-wrapped lines inside a paragraph are rejoined, so the Markdown does not inherit * the source layout's line breaks. */ -fun plainTextBlocks(text: String, reflow: Boolean = true): List { +internal fun plainTextBlocks(text: String, reflow: Boolean = true): List { // Form feeds mark PDF page breaks; treat them as paragraph boundaries. val normalized = text.replace("\r\n", "\n").replace('\r', '\n').replace('\u000C', '\n') val vocabulary = if (reflow) wordsIn(normalized) else emptySet() diff --git a/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/MikroMarkdownFactory.kt b/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/MikroMarkdownFactory.kt index d08cb64..8adc31c 100644 --- a/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/MikroMarkdownFactory.kt +++ b/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/MikroMarkdownFactory.kt @@ -15,7 +15,7 @@ import io.github.lemcoder.mikromarkdown.utils.TikaMimeDetector import java.io.File /** A [MikroMarkdown] with every JVM converter registered and Tika-based format detection. */ -fun MikroMarkdown(): MikroMarkdown = +public fun MikroMarkdown(): MikroMarkdown = MikroMarkdown(TikaMimeDetector).apply { register(MarkdownPassthroughConverter()) register(HtmlConverter()) @@ -30,4 +30,4 @@ fun MikroMarkdown(): MikroMarkdown = register(PlainTextConverter(), priority = 10.0) } -fun MikroMarkdown.convert(file: File): ConversionResult = convert(file.absolutePath) +public fun MikroMarkdown.convert(file: File): ConversionResult = convert(file.absolutePath) diff --git a/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/PdfConverter.kt b/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/PdfConverter.kt index 94543a8..58a536d 100644 --- a/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/PdfConverter.kt +++ b/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/converters/PdfConverter.kt @@ -7,7 +7,7 @@ import io.github.lemcoder.mikromarkdown.utils.plainTextBlocks import org.apache.pdfbox.Loader import org.apache.pdfbox.text.PDFTextStripper -class PdfConverter : DocumentConverter { +public class PdfConverter : DocumentConverter { override fun accepts(bytes: ByteArray, info: StreamInfo): Boolean { return info.extension == "pdf" || info.mimetype == "application/pdf" } diff --git a/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/utils/TikaMimeDetector.kt b/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/utils/TikaMimeDetector.kt index f819614..80ab826 100644 --- a/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/utils/TikaMimeDetector.kt +++ b/library/src/jvmMain/kotlin/io/github/lemcoder/mikromarkdown/utils/TikaMimeDetector.kt @@ -5,7 +5,7 @@ import io.github.lemcoder.mikromarkdown.StreamInfo import java.io.File import org.apache.tika.Tika -object TikaMimeDetector : MimeDetector { +internal object TikaMimeDetector : MimeDetector { private val tika = Tika() override fun detect(path: String): StreamInfo { diff --git a/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/CsvConverter.kt b/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/CsvConverter.kt index 9f11ac2..3ff1b60 100644 --- a/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/CsvConverter.kt +++ b/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/CsvConverter.kt @@ -9,7 +9,7 @@ import java.io.InputStreamReader import org.apache.commons.csv.CSVFormat import org.apache.commons.csv.CSVParser -class CsvConverter : DocumentConverter { +public class CsvConverter : DocumentConverter { override fun accepts(bytes: ByteArray, info: StreamInfo): Boolean { return info.extension == "csv" || info.mimetype in setOf("text/csv", "application/csv") } diff --git a/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/DocxConverter.kt b/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/DocxConverter.kt index 79a154a..596863f 100644 --- a/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/DocxConverter.kt +++ b/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/DocxConverter.kt @@ -21,7 +21,7 @@ import org.apache.poi.xwpf.usermodel.XWPFDocument import org.apache.poi.xwpf.usermodel.XWPFParagraph import org.apache.poi.xwpf.usermodel.XWPFTable -class DocxConverter : DocumentConverter { +public class DocxConverter : DocumentConverter { override fun accepts(bytes: ByteArray, info: StreamInfo): Boolean { return info.extension == "docx" || info.mimetype == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" diff --git a/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/EpubConverter.kt b/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/EpubConverter.kt index 2542a64..b700606 100644 --- a/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/EpubConverter.kt +++ b/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/EpubConverter.kt @@ -14,7 +14,7 @@ import javax.xml.parsers.DocumentBuilderFactory import org.w3c.dom.Element import org.xml.sax.InputSource -class EpubConverter : DocumentConverter { +public class EpubConverter : DocumentConverter { private val metaFields = listOf( "title" to "Title", diff --git a/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/HtmlConverter.kt b/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/HtmlConverter.kt index b716b5d..fee70f9 100644 --- a/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/HtmlConverter.kt +++ b/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/HtmlConverter.kt @@ -5,7 +5,7 @@ import io.github.lemcoder.mikromarkdown.StreamInfo import io.github.lemcoder.mikromarkdown.model.Document import io.github.lemcoder.mikromarkdown.utils.HtmlToDocument -class HtmlConverter : DocumentConverter { +public class HtmlConverter : DocumentConverter { override fun accepts(bytes: ByteArray, info: StreamInfo): Boolean { return info.extension in setOf("html", "htm") || info.mimetype in setOf("text/html", "application/xhtml+xml") } diff --git a/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/JsonConverter.kt b/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/JsonConverter.kt index 9b4dbf4..d5348f2 100644 --- a/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/JsonConverter.kt +++ b/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/JsonConverter.kt @@ -10,7 +10,7 @@ import io.github.lemcoder.mikromarkdown.StreamInfo import io.github.lemcoder.mikromarkdown.model.CodeBlock import io.github.lemcoder.mikromarkdown.model.Document -class JsonConverter : DocumentConverter { +public class JsonConverter : DocumentConverter { private val writer = ObjectMapper() .apply { registerKotlinModule() } diff --git a/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/PptxConverter.kt b/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/PptxConverter.kt index 78578a7..6fa57dc 100644 --- a/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/PptxConverter.kt +++ b/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/PptxConverter.kt @@ -33,7 +33,7 @@ import org.openxmlformats.schemas.drawingml.x2006.chart.CTScatterSer import org.openxmlformats.schemas.drawingml.x2006.chart.CTSerTx import org.openxmlformats.schemas.presentationml.x2006.main.CTPicture -class PptxConverter : DocumentConverter { +public class PptxConverter : DocumentConverter { override fun accepts(bytes: ByteArray, info: StreamInfo): Boolean { return info.extension == "pptx" || info.mimetype == "application/vnd.openxmlformats-officedocument.presentationml.presentation" diff --git a/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/XlsxConverter.kt b/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/XlsxConverter.kt index e601f5c..9255b53 100644 --- a/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/XlsxConverter.kt +++ b/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/XlsxConverter.kt @@ -14,7 +14,7 @@ import org.apache.poi.ss.usermodel.CellType import org.apache.poi.ss.usermodel.DataFormatter import org.apache.poi.xssf.usermodel.XSSFWorkbook -class XlsxConverter : DocumentConverter { +public class XlsxConverter : DocumentConverter { private val formatter = DataFormatter() override fun accepts(bytes: ByteArray, info: StreamInfo): Boolean { diff --git a/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/XmlConverter.kt b/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/XmlConverter.kt index 7d3e6ac..087e22e 100644 --- a/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/XmlConverter.kt +++ b/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/converters/XmlConverter.kt @@ -13,7 +13,7 @@ import javax.xml.transform.dom.DOMSource import javax.xml.transform.stream.StreamResult import org.xml.sax.InputSource -class XmlConverter : DocumentConverter { +public class XmlConverter : DocumentConverter { override fun accepts(bytes: ByteArray, info: StreamInfo): Boolean { return info.extension == "xml" || info.mimetype in setOf("text/xml", "application/xml") } diff --git a/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/utils/HtmlToDocument.kt b/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/utils/HtmlToDocument.kt index 510e036..1c2684d 100644 --- a/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/utils/HtmlToDocument.kt +++ b/library/src/jvmShared/kotlin/io/github/lemcoder/mikromarkdown/utils/HtmlToDocument.kt @@ -32,7 +32,7 @@ import org.jsoup.nodes.TextNode * Replaces the previous HTML → Markdown string conversion: tables, lists and inline emphasis become model nodes, so the * Markdown renderer owns all syntax decisions. */ -object HtmlToDocument { +internal object HtmlToDocument { private val DROPPED_TAGS = setOf( @@ -73,7 +73,7 @@ object HtmlToDocument { "colgroup", ) - fun parse(html: String, baseUri: String = ""): Document { + internal fun parse(html: String, baseUri: String = ""): Document { val doc = Jsoup.parse(html, baseUri) doc.select(DROPPED_TAGS.joinToString(", ")).remove() val title = doc.title().ifBlank { null } diff --git a/library/src/jvmTest/kotlin/io/github/lemcoder/mikromarkdown/ArchitectureTest.kt b/library/src/jvmTest/kotlin/io/github/lemcoder/mikromarkdown/ArchitectureTest.kt index a604680..4e2fe60 100644 --- a/library/src/jvmTest/kotlin/io/github/lemcoder/mikromarkdown/ArchitectureTest.kt +++ b/library/src/jvmTest/kotlin/io/github/lemcoder/mikromarkdown/ArchitectureTest.kt @@ -6,6 +6,7 @@ import com.lemonappdev.konsist.api.architecture.Layer import com.lemonappdev.konsist.api.verify.assertFalse import com.lemonappdev.konsist.api.verify.assertTrue import kotlin.test.Test +import kotlin.test.assertEquals /** * Guards the pipeline's boundaries: converters parse into the model, the renderer serializes it, and neither reaches @@ -14,11 +15,14 @@ import kotlin.test.Test */ class ArchitectureTest { - private val production = Konsist.scopeFromProject(sourceSetName = null).files.filterNot { it.path.contains("Test") } + private val scope = Konsist.scopeFromProject() + private val production = scope.files.filterNot { it.path.contains("Test") } + + // ---- layering ------------------------------------------------------------------------- @Test fun `layers depend in one direction only`() { - Konsist.scopeFromProject().assertArchitecture { + scope.assertArchitecture { val model = Layer("Model", "io.github.lemcoder.mikromarkdown.model..") val render = Layer("Render", "io.github.lemcoder.mikromarkdown.render..") val converters = Layer("Converters", "io.github.lemcoder.mikromarkdown.converters..") @@ -32,9 +36,14 @@ class ArchitectureTest { @Test fun `converters do not reach into the renderer`() { - production - .filter { it.packagee?.name?.contains(".converters") == true } - .assertFalse { file -> file.hasImport { it.name.contains(".render.") } } + productionIn(".converters").assertFalse { file -> file.hasImport { it.name.contains(".render.") } } + } + + @Test + fun `converters do not depend on each other`() { + productionIn(".converters").assertFalse { file -> + file.hasImport { it.name.contains(".converters.") && !it.name.endsWith(file.name.removeSuffix(".kt")) } + } } @Test @@ -47,17 +56,74 @@ class ArchitectureTest { } @Test - fun `converters implement DocumentConverter and are named accordingly`() { - Konsist.scopeFromProject() - .classes() - .filter { it.resideInPackage("..converters..") && it.name.endsWith("Converter") } - .assertTrue { it.hasParentInterface { parent -> parent.name == "DocumentConverter" } } + fun `the model stays free of platform dependencies`() { + productionIn(".model").assertFalse { file -> + file.hasImport { it.name.startsWith("java.") || it.name.startsWith("android.") } + } } + // ---- encapsulation -------------------------------------------------------------------- + @Test - fun `the model stays free of platform dependencies`() { - production + fun `helpers under utils stay internal`() { + val utils = { name: String? -> name?.contains(".utils") == true } + + // internal or private: anything but part of the published API. + scope.classes().filter { utils(it.packagee?.name) }.assertFalse { it.hasPublicOrDefaultModifier } + scope.objects().filter { utils(it.packagee?.name) }.assertFalse { it.hasPublicOrDefaultModifier } + scope + .functions() + .filter { utils(it.packagee?.name) && it.isTopLevel } + .assertFalse { it.hasPublicOrDefaultModifier } + } + + /** Builders hold mutable state by nature; the model must never expose any. */ + @Test + fun `the model exposes no mutable state`() { + scope + .properties() .filter { it.packagee?.name?.contains(".model") == true } - .assertFalse { file -> file.hasImport { it.name.startsWith("java.") || it.name.startsWith("android.") } } + .assertFalse { it.isVar && it.hasPublicOrDefaultModifier } + } + + @Test + fun `converters implement DocumentConverter, are named for it, and live together`() { + scope + .classes() + .filter { it.hasParentInterface { parent -> parent.name == "DocumentConverter" } } + .assertTrue { it.name.endsWith("Converter") && it.resideInPackage("..converters..") } + } + + // ---- hygiene -------------------------------------------------------------------------- + + @Test + fun `imports are not wildcards`() { + scope.files.assertFalse { file -> file.hasImport { it.isWildcard } } + } + + @Test + fun `production code does not print`() { + val printCall = Regex("""\bprintln?\(""") + + production.assertFalse { printCall.containsMatchIn(it.text) } + } + + /** + * JVM and Android share one source set; only the factory and the PDF converter differ, because pdfbox-android is a + * separate library. Any other same-named file in two source sets means a copy that will drift. + */ + @Test + fun `production files are not copied between source sets`() { + val expectedPerTarget = setOf("MikroMarkdownFactory", "PdfConverter") + + val copied = + production.filter { it.path.contains("/src/") }.groupBy { it.name }.filterValues { it.size > 1 }.keys - + expectedPerTarget + + assertEquals(emptySet(), copied, "these files exist in more than one source set") + } + + private fun productionIn(packageFragment: String) = production.filter { + it.packagee?.name?.contains(packageFragment) == true } } diff --git a/scripts/benchmark.py b/scripts/benchmark.py index 7f3c638..a669c28 100644 --- a/scripts/benchmark.py +++ b/scripts/benchmark.py @@ -8,7 +8,8 @@ Engines are skipped (not failed) when their CLI is unavailable: mikromarkdown cli/build/install/cli/bin/cli markitdown `markitdown` on PATH, else `uvx markitdown[all]` - anydoc `anydoc` on PATH, else a local npm install of @firecrawl/anydoc + anydoc `anydoc` on PATH, a cargo build in third-party/anydoc, or a local + npm install of @firecrawl/anydoc Metrics per output: content recall tokens agreed on by >=2 engines that this engine also emits @@ -87,6 +88,7 @@ def which_engines() -> list[Engine]: anydoc_bin = shutil.which("anydoc") if not anydoc_bin: for candidate in ( + REPO / "third-party/anydoc/target/release/anydoc", REPO / "build/anydoc/node_modules/.bin/anydoc", Path("/tmp/anydoc-bench/node_modules/.bin/anydoc"), ):