MinerU Markdown Rendering#
The markdown rendering stage converts the structured para_blocks from middle.json into final text output. It sits at the end of the extraction pipeline: model.json → middle.json → Markdown / content_list. The primary entry point is union_make(), which accepts the full pdf_info list from middle.json and a make_mode flag. It supports four modes :
| Mode | Output |
|---|---|
MakeMode.MM_MD | Markdown with images/tables as ![]() / HTML |
MakeMode.NLP_MD | Text-only markdown (visual blocks skipped) |
MakeMode.CONTENT_LIST | Flat structured list (content_list.json) |
MakeMode.CONTENT_LIST_V2 | Per-page typed content list (content_list_v2.json) |
union_make is backend-specific: _select_union_make() in client_side_output.py routes to the correct implementation for pipeline, vlm, hybrid, or office backends. The pipeline implementation described here lives in pipeline_middle_json_mkcontent.py.
Design principle: The renderer preserves extracted content — it does not infer or add markdown formatting elements (e.g., bullet prefixes) that were not present in the source document.
Block-Type Dispatch#
make_blocks_to_markdown() iterates every para_block in a page's para_blocks and dispatches based on block['type'] :
- TEXT, LIST, INDEX, ABSTRACT, REF_TEXT →
merge_para_with_text() - TITLE →
merge_para_with_text()prefixed with#repeatedleveltimes viaget_title_level() - INTERLINE_EQUATION → latex string wrapped in display delimiters, or
![]()image path if no LaTeX content - IMAGE, TABLE, CHART → skipped in
NLP_MD; rendered viamerge_visual_blocks_to_markdown()inMM_MD - CODE → always rendered via
merge_visual_blocks_to_markdown()
Blocks that produce an empty string after stripping are silently dropped. Final page results are joined with \n\n .
Text Block Rendering#
merge_para_with_text() routes to the internal _merge_para_text(), which runs three phases:
- Language detection — collects all TEXT span content to detect CJK vs. Latin via
detect_lang() - Span rendering —
_render_span()maps each span by type:TEXT→ full-width normalized + markdown-escaped stringINLINE_EQUATION→$...$(or custom delimiters from config)INTERLINE_EQUATION→ display-mode block ($$\n...\n$$)- All other types →
None(skipped)
- Span joining —
_join_rendered_span()appends a trailing space after each non-last span for Latin text; no space for CJK spans at line end. A Latin line ending with a hyphen is checked against the next line: if it starts with a lowercase letter, the hyphen is stripped and the words are merged .
Markdown escaping is applied in two layers : escape_conservative_markdown_text() escapes inline markers (* _ ~ $) within span content, and escape_text_block_markdown_prefix() escapes leading block markers (# + -`) on the assembled paragraph to prevent accidental heading/list interpretation.
Code blocks: if a CODE_BODY block has sub_type = CODE, merge_para_with_text() emits a fenced code block using guess_lang (defaults to txt) .
List Rendering#
LIST and INDEX blocks follow the same merge_para_with_text() path as TEXT blocks — no - or 1. prefix is synthesized. Instead, any bullet or number characters are preserved verbatim from the extracted span content, as they were present in the original document.
Item boundaries are communicated via line-level tags set earlier by para_split. ListLineTag defines two string keys:
IS_LIST_START_LINE— marks the first line of a new list itemIS_LIST_END_LINE— marks the last line of an item
During rendering, _line_prefix() checks IS_LIST_START_LINE on each line (starting from the second line of the block) and, if set, inserts a hard break ( \n) before that line — creating a visual separation between items without adding new markdown list syntax .
For content_list_v2 output, _split_list_item_blocks() re-splits a merged LIST block back into individual per-item TEXT blocks using the same IS_LIST_START_LINE boundaries, then each item is serialized independently into list_items .
The IS_LIST_START_LINE tags are set by __is_list_or_index_block() in para_split using multiple geometric/textual heuristics , including: lines flush-left with ragged right edges, lines ending with punctuation flags, lines starting with digits (ordered lists), and centered lines.
Visual Block Rendering#
merge_visual_blocks_to_markdown() handles IMAGE, TABLE, CHART, and CODE compound blocks. It iterates sub-blocks in index field order via get_blocks_in_index_order(), then calls render_visual_block_segments() on each, which returns a list of (text, segment_kind) tuples.
Segment kinds and separators :
| Transition | Separator |
|---|---|
After html_block | \n\n (required so following text isn't parsed as HTML) |
Into/out of details_block | \n\n |
Into html_block | \n |
Between markdown_line segments | \n (hard break) |
Per sub-block rendering :
- Caption/footnote sub-blocks (image, table, chart, code) →
merge_para_with_text()→markdown_line - IMAGE_BODY →
markdown_line + optional<details>VLM description block ifspan['content']is present - CHART_BODY →
markdown_line - TABLE_BODY → HTML string as
html_block(preferred), or![]()fallback;_format_embedded_html()prefixes relativesrc=attributes and replaces<eq>...</eq>tags with inline math delimiters - CODE_BODY → fenced code block via
merge_para_with_text()(inheritssub_type/guess_langfrom parent via_inherit_parent_code_render_metadata())
Key Source Files#
| File | Role |
|---|---|
pipeline_middle_json_mkcontent.py | Core rendering logic: union_make, make_blocks_to_markdown, merge_para_with_text, merge_visual_blocks_to_markdown |
para_split.py | Sets ListLineTag.IS_LIST_START_LINE / IS_LIST_END_LINE on lines; classifies LIST vs INDEX vs TEXT blocks |
markdown_utils.py | escape_conservative_markdown_text, escape_text_block_markdown_prefix |
client_side_output.py | _select_union_make() — routes to backend-specific union_make |
enum_class.py | BlockType, ContentType, ContentTypeV2, MakeMode constants |