Plugin Authoring Guide for md2pdf
This document describes the public API available to third-party plugin authors, the four hook stages, and how to publish a plugin package.
Overview
md2pdf processes a Markdown document through four sequential stages. Plugins can intercept at any of the first three stages, plus supply stylesheet overrides:
[ Raw .md ]
│
▼
┌─────────────────────────────────┐
│ Stage 1: Pre-Processors │ ← hook: transform raw Markdown text
│ (PreProcessorRegistry) │
└─────────────────┬───────────────┘
│
▼
┌─────────────────────────────────┐
│ Stage 2: AST Parser │ (not pluggable — mistletoe is fixed)
└─────────────────┬───────────────┘
│
▼
┌─────────────────────────────────┐
│ Stage 3: Element Handlers │ ← hook: add / replace token → Flowable
│ (HandlerRegistry dispatch) │
└─────────────────┬───────────────┘
│
▼
┌─────────────────────────────────┐
│ Stage 4: Post-Processors │ ← hook: mutate the Flowable list
│ (PostProcessorRegistry) │ e.g. watermarks, page numbers, TOC
└─────────────────┬───────────────┘
│
▼
[ PDF Output ]
Additionally, plugins can supply a stylesheet layer that is merged with
the default styles before any handler sees the styles dict.
Public API
Only the following modules are part of the public, stable API. Do not
import from md2pdf.core._* private internals.
| Module | Public symbols |
|---|---|
md2pdf.core.registry |
ElementHandler |
md2pdf.core.preprocessors |
PreProcessor, PreProcessorRegistry |
md2pdf.core.postprocessors |
PostProcessor, PostProcessorRegistry |
md2pdf.core.styles |
StyleRegistry |
Hook 1 — Pre-Processor
Subclass PreProcessor and implement process(raw_md: str) -> str.
from md2pdf.core.preprocessors import PreProcessor
class MyPreProcessor(PreProcessor):
def process(self, raw_md: str) -> str:
# Replace all occurrences of ":warning:" with ⚠️
return raw_md.replace(":warning:", "⚠️")
Priority: Declare under md2pdf.preprocessors entry points. The
register() call accepts a priority keyword (default 50). Built-ins run
at priorities 10 and 20, so plugin pre-processors at priority 50 always run
after them.
Hook 2 — Element Handler
Subclass ElementHandler, set token_type, and implement render().
from md2pdf.core.registry import ElementHandler
from reportlab.platypus import Paragraph
from reportlab.lib.styles import getSampleStyleSheet
class CalloutHandler(ElementHandler):
"""Render custom ``Callout`` token blocks as styled paragraphs."""
token_type = "Callout"
def render(self, token: dict, styles: dict) -> list:
text = token.get("raw", "")
style = getSampleStyleSheet()["Normal"]
return [Paragraph(f"📢 {text}", style)]
The styles dict is the merged stylesheet (defaults + plugin layers). You
can add your own style keys to it via a stylesheet plugin (Hook 4).
Last-writer-wins: registering a handler for a token_type that already
has one (e.g. "Heading") replaces the built-in handler.
Hook 3 — Post-Processor
Subclass PostProcessor and implement
process(doc: SimpleDocTemplate, flowables: list) -> list.
from reportlab.platypus import SimpleDocTemplate
from md2pdf.core.postprocessors import PostProcessor
class WatermarkPostProcessor(PostProcessor):
"""Stamps 'DRAFT' diagonally on every page."""
def __init__(self, text: str = "DRAFT") -> None:
self.text = text
def process(self, doc: SimpleDocTemplate, flowables: list) -> list:
# Attach an onPage callback to the document.
text = self.text
def _stamp(canvas, doc):
canvas.saveState()
canvas.setFont("Helvetica-Bold", 72)
canvas.setFillAlpha(0.1)
canvas.translate(doc.pagesize[0] / 2, doc.pagesize[1] / 2)
canvas.rotate(45)
canvas.drawCentredString(0, 0, text)
canvas.restoreState()
doc._md2pdf_on_later_pages = _stamp # type: ignore
return flowables
Post-processors run in registration order. The flowables list returned by each processor is passed as input to the next.
Hook 4 — Stylesheet Override
Implement a class with a get_stylesheet() -> dict method. Return a dict
whose keys override the default stylesheet values.
class DarkTheme:
def get_stylesheet(self) -> dict:
return {
"color_body_text": "#e0e0e0",
"color_link": "#80bfff",
"font_size_body": 11,
}
Entry-Point Declaration
In your plugin package's pyproject.toml:
[project.entry-points."md2pdf.handlers"]
callout = "my_package.handlers:CalloutHandler"
timeline = "my_package.handlers:TimelineHandler"
[project.entry-points."md2pdf.preprocessors"]
emoji = "my_package.preprocessors:EmojiPreProcessor"
[project.entry-points."md2pdf.postprocessors"]
watermark = "my_package.postprocessors:WatermarkPostProcessor"
[project.entry-points."md2pdf.stylesheets"]
dark_theme = "my_package.themes:DarkTheme"
After installation (pip install my-plugin-package), md2pdf auto-discovers
and loads the plugin on every run — no config file changes needed.
Config-File Alternative
If you cannot use entry points (e.g. local scripts), declare plugins in
md2pdf.toml:
[plugins]
handlers = [
"my_package.handlers:CalloutHandler",
]
preprocessors = [
"my_package.preprocessors:EmojiPreProcessor",
]
postprocessors = [
"my_package.postprocessors:WatermarkPostProcessor",
]
Config-file plugins are loaded after entry-point plugins, so they can override built-ins declared via entry points.
Error Handling Contract
- All plugin loading errors are caught and logged — a bad plugin never aborts a conversion run.
- Errors appear at
ERRORlevel in themd2pdf.core.plugin_loaderlogger. - Enable logging in your application to see plugin load failures:
Rules for Plugin Authors
- Only subclass public ABCs (
ElementHandler,PreProcessor,PostProcessor). Do not subclass internal implementation classes. - Return valid
Flowableinstances fromElementHandler.render(). - Never raise from
render()orprocess()— return a fallback value instead. An unhandled exception will propagate and crash the conversion. - Declare dependencies in your
pyproject.toml— do not assume md2pdf's dependencies (reportlab, mistletoe, requests) are available beyond their published API. - Pin to
md2pdf >= 0.1.0— the public API is stable from this version.