Parse documents into a shared model, render Markdown once - #1
Merged
Conversation
Every converter used to build Markdown strings directly, so escaping, table
shaping, list indentation and blank-line handling were reimplemented (and drifted)
across nine converters in two source sets.
Converters now parse into a format-independent Document model — blocks, inlines,
tables with spans, and embedded assets — and a single MarkdownRenderer serializes
it to GFM. Fixes to output quirks now land in one place.
- DocumentConverter.convert -> parse(bytes, info): Document; MikroMarkdown renders
- MarkdownRenderer owns escaping, heading clamping, nested lists, GFM tables
(ragged rows padded, colspan expanded, pipes escaped, newlines -> <br>) and
fence sizing; MarkdownNormalizer's post-hoc regex pass is gone
- HTML/EPUB go through a Jsoup DOM -> model walker instead of flexmark, which is
dropped as a dependency
- Document and DocumentBuilder are public: MikroMarkdown.parse() returns the model
and ConversionResult carries it, so callers can render with their own options
Also adds scripts/benchmark.py, comparing output against Python markitdown and
Rust anydoc. It found three defects, fixed here:
- the Log4j banner PDFBox triggers was printed to stdout, corrupting CLI Markdown
- PDF text became a single paragraph; PDFBox paragraph formatting is now enabled
- de-hyphenation merged real compounds ("conversation-centric"); it now keeps the
hyphen when both fragments appear as standalone words in the document
PythonComparisonTest normalizes Unicode spacing (Java's \s ignores hair/thin
spaces, Python's does not) and ignores <noscript> tracking pixels, which we drop.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follows the anydoc architecture: every format parses into one document model, and a single serializer produces the Markdown.
Before, nine converters × two source sets each built Markdown strings by hand, so pipe escaping, heading clamping, table shaping and blank-line handling were reimplemented per format — and drifted.
Changes
model/Document.kt—Document(blocks, title, metadata, assets). Blocks:Heading,Paragraph,CodeBlock,BlockQuote,ListBlock/ListItem,Table(cells with colspan/rowspan),ThematicBreak,HtmlComment,RawBlock. Inlines:Text,Strong,Emphasis,Strikethrough,CodeSpan,Link,Image,LineBreak,RawInline.model/DocumentBuilder.kt—document { … }DSL so parsers stay free of Markdown syntax.render/MarkdownRenderer.kt— the only place Markdown syntax is produced. Context-aware escaping (intraword_left alone), heading clamp, nested-list indentation, GFM tables (ragged rows padded, colspan expanded, pipes escaped, newlines →<br>), fence-length bump.MarkdownOptionscovers padded tables, YAML front matter, images-as-text and URL length caps.MarkdownNormalizer's post-hoc regex pass is gone.DocumentConverter.convert→parse(bytes, info): Document;MikroMarkdownrenders.MikroMarkdown.parse()andConversionResult.documentexpose the model, so callers can re-render with their own options.HtmlToDocument) instead of flexmark, which is dropped as a dependency.Benchmark
scripts/benchmark.pyconverts the fixtures with MikroMarkdown, Python markitdown and Rust anydoc, then reports content recall, structure counts, table integrity and timings tobuild/benchmark/report.md. Recall is measured against the consensus vocabulary: tokens at least two engines agree on.Timings: anydoc ~25 ms, MikroMarkdown 140–450 ms warm (JVM startup measured separately at ~290 ms), markitdown 400–580 ms.
The benchmark found three defects, all fixed here:
conversation-centric→conversationcentric). It now keeps the hyphen when both fragments appear as standalone words elsewhere in the document, while still repairingflexibil-/ity. markitdown and anydoc leave both broken.Tests
:library:jvmTestand:library:testAndroidHostTestpass. Two normalizations were added toPythonComparisonTestso the comparison stays fair: Java's\signores hair/thin spaces where Python's does not, and markitdown keeps<noscript>tracking pixels that we drop.Note
DOCX output is 161 KB against their 4.6 KB, because embedded images are inlined as base64 data URIs. The bytes now also live in
Document.assets, soMarkdownOptions(imagesAsText = true)ormaxInlineImageUrldrops the data URI without losing them. Default left unchanged.🤖 Generated with Claude Code