Skip to content

Markdown Conversion

PDF Oxide converts PDF pages to clean, readable Markdown. The conversion pipeline extracts text spans, clusters them into lines, consults the /StructTreeRoot for headings and list roles on Tagged PDFs, detects multi-column gutters and backward-x reading-order wraps, groups paragraphs, and emits Markdown syntax.

Since v0.3.36, for Tagged PDFs the converter reads StructRole(Heading(1..6) | ListItem | ListItemLabel | ListItemBody) directly from /StructTreeRoot instead of re-deriving heading levels from font size. Role information is propagated through nested MCRs (H1 → Span → MCR, LI → LBody → Span → MCR). For untagged documents, the geometric fallback still applies: bold + 5 % size bump promotes to H4, and is_ordered_list_marker recognises 1. / 12. / a) / iv. / A. while rejecting figure captions and years.

Multi-column handling: same-baseline spans separated by > max(3 × font_size, 30 pt) are treated as cross-column. Backward-x reading-order wraps (column-major last→first-span) break paragraphs instead of joining them into nonsense tokens.

RTL: bidi reorder is off by default — the earlier unconditional visual→logical reorder broke logical-order PDFs (Hebrew בנימין was being reversed). Spurious **bold** markers around Arabic contextual glyphs are stripped. Callers whose input is in visual order can invoke text::bidi::reorder_visual_to_logical manually (Rust).

Inline images are capped at 200 KB base64 payload (added v0.3.36). Over-the-cap images emit an HTML comment noting the original size; use image_output_dir to write them to disk instead.

Quick Example

Python

from pdf_oxide import PdfDocument

doc = PdfDocument("paper.pdf")
md = doc.to_markdown(0, detect_headings=True)
print(md)

Node.js

const { PdfDocument } = require("pdf-oxide");

const doc = new PdfDocument("paper.pdf");
const md = doc.toMarkdown(0, { detectHeadings: true });
console.log(md);
doc.close();

Go

import pdfoxide "github.com/yfedoseev/pdf_oxide/go"

doc, _ := pdfoxide.Open("paper.pdf")
defer doc.Close()
md, _ := doc.ToMarkdown(0)
fmt.Println(md)

C#

using PdfOxide.Core;

using var doc = PdfDocument.Open("paper.pdf");
var md = doc.ToMarkdown(0);
Console.WriteLine(md);

WASM

const doc = new WasmPdfDocument(bytes);
const md = doc.toMarkdown(0, true);
console.log(md);

Rust

use pdf_oxide::PdfDocument;
use pdf_oxide::converters::ConversionOptions;

let mut doc = PdfDocument::open("paper.pdf")?;
let options = ConversionOptions { detect_headings: true, ..Default::default() };
let md = doc.to_markdown(0, &options)?;
println!("{}", md);

API Reference

to_markdown(page_index, ...) -> str

Convert a single page to Markdown.

Python Signature

doc.to_markdown(
    page: int,
    preserve_layout: bool = False,
    detect_headings: bool = True,
    include_images: bool = True,
    image_output_dir: str | None = None,
    embed_images: bool = True,
) -> str

JavaScript Signature

doc.toMarkdown(pageIndex, detectHeadings?, includeImages?, includeFormFields?) -> string

Rust Signature

pub fn to_markdown(
    &mut self,
    page_index: usize,
    options: &ConversionOptions,
) -> Result<String>
Parameter Type Default Description
page_index int / usize / number Zero-based page index
preserve_layout bool false Preserve visual layout positioning
detect_headings bool true Detect headings based on font size and weight
include_images bool true Include images in output
image_output_dir str / None None Directory to save extracted images (Python/Rust only). Unaffected by the 200 KB inline cap.
embed_images bool true Embed images as base64 data URIs (Python/Rust only). Payloads over 200 KB emit a placeholder HTML comment noting the original size (v0.3.36).
include_form_fields bool true Include form field values (Python/JS)

Returns: Markdown string for the page.


to_markdown_all(...) -> str

Convert all pages to Markdown, separated by horizontal rules (---).

Python Signature

doc.to_markdown_all(
    preserve_layout: bool = False,
    detect_headings: bool = True,
    include_images: bool = True,
    image_output_dir: str | None = None,
    embed_images: bool = True,
) -> str

JavaScript Signature

doc.toMarkdownAll(detectHeadings?, includeImages?, includeFormFields?) -> string

Rust Signature

pub fn to_markdown_all(
    &mut self,
    options: &ConversionOptions,
) -> Result<String>
Parameter Type Default Description
preserve_layout bool false Preserve visual layout
detect_headings bool true Detect headings
include_images bool true Include images
image_output_dir str / None None Image output directory
embed_images bool true Embed images as base64

Returns: Markdown string for all pages joined with --- separators.


to_markdown_with_ocr(page_index, model_path, options) -> str

Convert a page to Markdown with OCR fallback for scanned pages. When the page has little or no extractable text, OCR is used to recognize text from the rendered page image. Requires the ocr feature.

Parameter Type Description
page_index usize Zero-based page index
model_path &str Path to the OCR model files
options &ConversionOptions Conversion options

Rust

let mut doc = PdfDocument::open("scanned.pdf")?;
let options = ConversionOptions { detect_headings: true, ..Default::default() };
let md = doc.to_markdown_with_ocr(0, "/path/to/models", &options)?;
println!("{}", md);

ConversionOptions

The ConversionOptions struct controls all conversion behavior.

Field Type Default Description
preserve_layout bool false Preserve visual layout with positioning
detect_headings bool true Auto-detect headings from font size clusters
extract_tables bool false Extract tables (experimental)
include_images bool true Include images in output
image_output_dir Option<String> None Save images to this directory
embed_images bool true Embed images as base64 data URIs
reading_order_mode ReadingOrderMode Auto How to determine reading order
bold_marker_behavior BoldMarkerBehavior Conservative Bold marker application strategy

How It Works

The Markdown conversion pipeline operates in several stages:

  1. Text Extraction – Extracts TextSpan objects from the page content stream, capturing text, position, font, size, weight, and color.

  2. Character Clustering – Groups characters into words based on inter-character gaps, then words into lines based on vertical proximity.

  3. Reading Order – Determines reading order using either the Tagged PDF structure tree (preferred) or a graph-based spatial analysis of text block positions.

  4. Heading Detection – When detect_headings is enabled, clusters font sizes across the page to identify heading levels. Larger and bolder text is mapped to #, ##, ### headings.

  5. Formatting – Applies bold (**text**) and italic (*text*) markers based on font weight and style metadata.

  6. Table Detection – Identifies tabular layouts using spatial analysis of aligned text blocks and emits GFM-style Markdown tables.

  7. Whitespace Cleanup – Normalizes spacing, removes redundant blank lines, and ensures consistent paragraph breaks.


Advanced Examples

Convert entire PDF to a Markdown file

Python

from pdf_oxide import PdfDocument

doc = PdfDocument("book.pdf")
md = doc.to_markdown_all(detect_headings=True)

with open("book.md", "w", encoding="utf-8") as f:
    f.write(md)

Node.js

const fs = require("node:fs");

const doc = new PdfDocument("book.pdf");
const md = doc.toMarkdownAll();
fs.writeFileSync("book.md", md);
doc.close();

Go

doc, _ := pdfoxide.Open("book.pdf")
defer doc.Close()
md, _ := doc.ToMarkdownAll()
os.WriteFile("book.md", []byte(md), 0644)

C#

using var doc = PdfDocument.Open("book.pdf");
var md = doc.ToMarkdownAll();
File.WriteAllText("book.md", md);

WASM

const doc = new WasmPdfDocument(bytes);
const md = doc.toMarkdownAll(true);
writeFileSync("book.md", md);
doc.free();

Convert with images saved to a directory

use pdf_oxide::PdfDocument;
use pdf_oxide::converters::ConversionOptions;

let mut doc = PdfDocument::open("report.pdf")?;
let options = ConversionOptions {
    detect_headings: true,
    include_images: true,
    embed_images: false,
    image_output_dir: Some("output/images".to_string()),
    ..Default::default()
};

let md = doc.to_markdown_all(&options)?;
std::fs::write("output/report.md", &md)?;

Page-by-page conversion with progress

from pdf_oxide import PdfDocument

doc = PdfDocument("report.pdf")
pages = doc.page_count()

parts = []
for i in range(pages):
    md = doc.to_markdown(i, detect_headings=True)
    parts.append(md)
    print(f"Converted page {i + 1}/{pages}")

full_md = "\n\n---\n\n".join(parts)
with open("report.md", "w") as f:
    f.write(full_md)

Disable heading detection for flat text

doc = PdfDocument("form.pdf")
md = doc.to_markdown(0, detect_headings=False)
# All text rendered as paragraphs, no # headings