Turn Markdown into beautifully themed PDFs — parsed with Flexmark, laid out by the GraphCompose engine.
graphcompose-markdown parses Markdown with Flexmark, maps the parse tree onto an independent semantic model, and renders that model through a swappable theme into the GraphCompose document engine — which owns measurement, layout, pagination and PDF output.
![]() DefaultMarkdownTheme.light() |
![]() DefaultMarkdownTheme.dark() |
This is not a plain Markdown-to-PDF converter. Three concerns stay separate:
- Content — your Markdown text.
- Appearance — a theme (colors, fonts, spacing, per-element styles, renderers).
- Layout — GraphCompose owns measurement, pagination and output.
The same Markdown can be reskinned into completely different documents without touching its text.
Markdown ──Flexmark──▶ Flexmark AST ──mapper──▶ Semantic model ──theme + renderers──▶ GraphCompose model ──engine──▶ PDF
(no Flexmark (MarkdownNode tree) (layout + pagination)
types downstream)
Status: first release (
0.1.0). The API may still change before1.0.0.
One Markdown document that uses every feature — and describes the library while doing
it — rendered straight to PDF. Open the source and the result:
showcase.md → showcase.pdf.
![]() |
![]() |
![]() |
Front-matter title block, headings → PDF outline, inline formatting, autolinks, emoji,
nested & task lists, syntax-highlighted code, GFM tables, all five GitHub alerts,
blockquotes, footnotes, ::: custom blocks and an embedded image — all on those pages.
- Separation of content, appearance and layout. Reskin a document by swapping a theme; the Markdown never changes.
- Parser-decoupled semantic model. Renderers operate on a sealed
MarkdownNodetree, never on Flexmark types — so the parser stays swappable and you can build or transform the model by hand. - Three-layer theming. Design tokens → component styles → node renderers. Override
exactly what you need with
MarkdownTheme.builder(base)and reuse everything else. - Composable renderer packs. Ship and combine sets of renderers, override a single
node type, or register a renderer for your own
:::block type. - Built-in syntax highlighting via a pluggable
SyntaxHighlighterSPI (no extra dependency for the default). - Real PDF layout — pagination, keep-together panels, GFM tables, vector list markers and footnotes, all from the GraphCompose engine.
- No mandatory font artifact. The default themes use the PDF base-14 fonts; JetBrains Mono is opt-in.
- Multiple entry points. Render a Markdown string, a pre-parsed Flexmark
Document, or a hand-built semantic model.
Not yet on Maven Central (first release pending). Until then, build from source (
./mvnw install) and depend on the snapshot, or consume the repo via JitPack.
Maven (once released):
<dependency>
<groupId>io.github.demchaav</groupId>
<artifactId>graph-compose-markdown</artifactId>
<version>0.1.0</version>
</dependency>Requires Java 17+. The GraphCompose engine (io.github.demchaav:graph-compose)
comes in transitively.
import io.github.demchaav.markdown.composer.MarkdownComposer;
import io.github.demchaav.markdown.theme.DefaultMarkdownTheme;
import java.nio.file.Path;
String md = """
# Release notes
GraphCompose **1.8** ships *themeable* Markdown rendering.
- Headings, lists and `inline code`
- Syntax-highlighted code blocks
- [Links](https://github.com/DemchaAV/GraphCompose)
> Themes decide how all of this looks.
""";
MarkdownComposer composer = MarkdownComposer.create(DefaultMarkdownTheme.light());
composer.render(md).writePdf(Path.of("release-notes.pdf"));
// or: byte[] pdf = composer.render(md).toPdfBytes();
// composer.render(md).writePdf(outputStream);Headings (h1–h6), paragraphs with inline bold / italic / strikethrough /
inline code / links (plus bare-URL autolinking), ordered & unordered (nested)
lists, task lists,
syntax-highlighted fenced code blocks, blockquotes, horizontal rules, images,
GFM tables (with per-column alignment), footnotes, GitHub-style alerts
(> [!NOTE] / [!TIP] / [!IMPORTANT] / [!WARNING] / [!CAUTION]), emoji
shortcodes (:rocket:), YAML front matter (a --- … --- title block), and
::: custom blocks (e.g. callouts).
Emoji shortcodes render as inline images when an EmojiResolver is configured
(e.g. ClasspathEmojiResolver with bundled Twemoji PNGs); otherwise they fall back to
readable :shortcode: text, since PDF fonts carry no emoji glyphs.
Headings also become a navigable PDF outline — the viewer's bookmark/outline pane mirrors the document's heading tree.
Content the library does not model (raw HTML blocks, inline HTML) is surfaced as raw
text rather than silently dropped; MarkdownComposer.builder().strictMode(true)
rejects such a document instead, for pipelines that must fail loudly.
MarkdownComposer.render(String)
│ Flexmark parser (+ GFM tables, task lists, strikethrough, footnotes)
▼
Flexmark AST
│ FlexmarkAstMapper — the boundary: nothing downstream imports Flexmark
▼
MarkdownDocument (sealed MarkdownNode tree: HeadingNode, ParagraphNode, ListNode,
│ CodeBlockNode, QuoteNode, TableNode, CustomBlockNode, …)
│ RendererRegistry — one NodeRenderer per node type, from the theme
▼
GraphCompose document model (sections, paragraphs, RichText, tables, panels)
│ GraphCompose engine
▼
Layout + pagination → PDF
The semantic model is the stable hand-off point: the parser is swappable, renderers never see Flexmark, and you can construct or transform the model directly. See docs/architecture.md for the full picture.
A MarkdownTheme is built from three layers, so you change exactly as much as you
need and reuse everything else:
- Design tokens (
MarkdownTokens) — cosmetic values: colors, fonts, sizes, spacing, borders, corner radii, page geometry, syntax-highlight colors. - Component styles (
MarkdownStyles) — per-element styles (HeadingStyle,CodeBlockStyle,ListStyle,QuoteStyle, …) derived from tokens. - Node renderers (
NodeRenderer) — the behaviour that turns each semantic node into GraphCompose builders, bound to node types in aRendererRegistry.
MarkdownTheme base = DefaultMarkdownTheme.light();
MarkdownTheme custom = MarkdownTheme.builder(base)
// layer 1 — reskin a cosmetic token
.tokens(base.tokens().withColors(
base.tokens().colors().withCodeBackground(DocumentColor.rgb(246, 248, 250))))
// layer 3 — swap one renderer, reuse every other component
.renderer(CodeBlockNode.class, new LabeledCodeBlockRenderer())
.build();Beyond DefaultMarkdownTheme.light() / .dark(), the
io.github.demchaav.markdown.theme.packs package ships drop-in themes — the same
Markdown, reskinned:
![]() GitHubTheme.light() |
![]() GitHubTheme.dark() |
![]() AcademicTheme.light() |
![]() MinimalTheme.light() |
![]() BusinessReportTheme.light() |
![]() DefaultMarkdownTheme.light() |
MarkdownComposer.create(GitHubTheme.dark()).render(md).writePdf(path);Full theming guide: docs/theming.md.
A NodeRenderer is a single method that turns one semantic node into GraphCompose
builders, reading all styling from the RenderContext:
NodeRenderer<CodeBlockNode> labeled = (node, host, ctx) -> {
// emit GraphCompose builders into `host`; read styling from `ctx`
host.addParagraph(p -> p.text(node.language().toUpperCase()));
// ...render the code body using ctx.styles(), ctx.highlighter(), …
};Bundle renderers into a pack, override a single node type, or register a renderer
for your own ::: block — reusing everything else:
MarkdownTheme theme = MarkdownTheme.builder(DefaultMarkdownTheme.light())
.pack(new MyAlertsPack()) // bundle renderers from another source
.renderer(CodeBlockNode.class, labeled) // override one node renderer
.customBlock("chart", new ChartRenderer()) // render your own ::: block type
.build();A :::chart … ::: block routes to the renderer registered for "chart"; any
unrecognised ::: type falls back to the callout style. Step-by-step guide (with a
full custom RendererPack): docs/custom-renderers.md.
Code highlighting uses a pluggable SyntaxHighlighter SPI. The built-in
RegexSyntaxHighlighter covers ~15 common languages with no extra dependency; plug a
grammar-based highlighter via MarkdownTheme.builder().highlighter(...). Colors come
from the theme's SyntaxColors token group (light/dark palettes).
The default themes use the PDF base-14 fonts (Helvetica / Times / Courier). To
render code in JetBrains Mono, add the bundled-fonts artifact and upgrade any
theme with BundledFonts:
<dependency>
<groupId>io.github.demchaav</groupId>
<artifactId>graph-compose-fonts</artifactId>
<version>1.0.0</version>
</dependency>MarkdownTheme theme = BundledFonts.jetBrainsMonoCode(DefaultMarkdownTheme.light());The dependency is declared optional, so it only ships if you ask for it.
Already have a Flexmark tree (parsed with your own parser and extensions), or build the semantic model yourself? Render either directly — no string round-trip, no file:
// a com.vladsch.flexmark.util.ast.Document you already parsed
composer.render(flexmarkDocument).toPdfBytes();
// or a hand-built / transformed MarkdownDocument semantic model
composer.render(markdownDocument).writePdf(out);(The ::: custom-block extraction is a text-level pre-pass, so it only runs for the
render(String) entry point.)
Runnable examples live in examples/ — render an inline string,
read a Markdown file and write a PDF, render the same content through every theme,
or wire a custom ::: block renderer. Build the library once, then run one:
./mvnw -B -ntp -DskipTests install # install the library into your local Maven repo
cd examples && ../mvnw exec:java \
-Dexec.mainClass=io.github.demchaav.markdown.examples.RenderMarkdownFileExample \
-Dexec.args="../README.md README.pdf"See examples/README.md for the full list.
- Architecture — the pipeline, the semantic model, and why the parser is decoupled.
- Theming — tokens, component styles, deriving themes, packs, syntax colors, rich fonts.
- Custom renderers — write a
NodeRenderer, aRendererPack, and custom:::block types. - Changelog — release notes.
./mvnw -B -ntp clean verify # compile + run the full test suite
./mvnw -B -ntp clean verify javadoc:javadoc # + Javadoc gateSee CONTRIBUTING.md for the branch workflow and commit style.
MIT © Artem Demchyshyn











