Skip to content

DemchaAV/graphcompose-markdown

Repository files navigation

graphcompose-markdown

Turn Markdown into beautifully themed PDFs — parsed with Flexmark, laid out by the GraphCompose engine.

Java 17+ License: MIT

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.

Light theme sample
DefaultMarkdownTheme.light()
Dark theme sample
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: 0.3.1 released. The API may still change before 1.0.0.

Showcase

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.mdshowcase.pdf.

Showcase — title block, alert, inline emoji, lists Showcase — highlighted code, aligned table, colour-coded alerts Showcase — an embedded reskinned render and the architecture diagram

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.

Why this library

  • 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 MarkdownNode tree, 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 SyntaxHighlighter SPI (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.

Install

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.3.1</version>
</dependency>

Requires Java 17+. The GraphCompose engine (io.github.demchaav:graph-compose) comes in transitively.

Quickstart

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.9** 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);

// Render a Markdown *file* directly — reads UTF-8 and resolves relative images
// (e.g. ![](diagram.png)) against the file's own folder:
composer.renderFile(Path.of("docs/guide.md")).writePdf(Path.of("guide.pdf"));

Usage at a glance

One composer, four ways in, three ways out. A composer is immutable and thread-safe — build it once and reuse it.

Take this in Call Notes
A Markdown string composer.render(String) null is treated as empty
A Markdown file composer.renderFile(Path) reads UTF-8; resolves relative images (![](pic.png)) against the file's own folder; throws IOException
A parsed Flexmark Document composer.render(Document) bring your own parser/extensions; ::: blocks are not extracted on this path
A hand-built MarkdownDocument composer.render(MarkdownDocument) the stable semantic-model hand-off point

Each returns a Rendered, with four ways out (all throw DocumentRenderingException on a render failure):

Get this out Call
Write to a file rendered.writePdf(Path)
Stream to an OutputStream rendered.writePdf(OutputStream)
In-memory byte[] rendered.toPdfBytes()
PNG page images (thumbnails, previews) rendered.toImages(dpi) / toImage(pageIndex, dpi)
MarkdownComposer composer = MarkdownComposer.create(GitHubTheme.dark());
composer.renderFile(Path.of("doc.md")).writePdf(Path.of("doc.pdf"));   // file → PDF
byte[] bytes = composer.render("# Hello").toPdfBytes();                 // string → bytes

Pick the look by passing a theme to create(...): DefaultMarkdownTheme.light() / .dark(), or a ready-made pack from io.github.demchaav.markdown.theme.packsGitHubTheme, AcademicTheme, MinimalTheme, BusinessReportTheme. No Java at all? The gcmd CLI renders a file straight from the shell.

Want page numbers? Footers are off by default; derive a theme with FooterTokens:

MarkdownTheme t = DefaultMarkdownTheme.light();
MarkdownTheme numbered = MarkdownTheme.builder(t)
        .tokens(t.tokens().withFooter(FooterTokens.pageNumbers())) // centred "Page N of M"
        .build();

Prefer typographic output? MarkdownComposer.builder().smartPunctuation(true) turns -- / --- / ... and straight quotes into – — … and “curly” quotes (code stays verbatim; off by default, matching GitHub).

Command-line (CLI)

A standalone cli/ module renders Markdown to PDF from the shell — no Java code required. Build the executable fat-jar (it bundles every dependency):

./mvnw -q -ntp install -DskipTests        # install the library to ~/.m2
./mvnw -f cli/pom.xml -q -ntp package     # -> cli/target/graph-compose-markdown-cli.jar

Then render:

# basic: writes README.pdf next to the input
java -jar cli/target/graph-compose-markdown-cli.jar README.md

# pick a theme, an output path, an image base dir and an emoji dir
java -jar cli/target/graph-compose-markdown-cli.jar docs/guide.md \
    -o build/guide.pdf -t github-dark -i docs/assets -e docs/emoji

# read from stdin
cat notes.md | java -jar cli/target/graph-compose-markdown-cli.jar - -o notes.pdf
Option Meaning
INPUT Markdown file, or - for stdin
-o, --output FILE Output PDF, or - to write the PDF to stdout (default: input name with .pdf, or out.pdf for stdin)
-t, --theme NAME light (default), dark, github-light, github-dark, academic, minimal, business
-i, --images DIR Base dir for relative image paths (default: the input file's dir)
-e, --emoji DIR Dir of <shortcode>.png files to render :shortcode: inline
--mono-jetbrains Render code in bundled JetBrains Mono
--strict Fail on unsupported Markdown instead of degrading
-h, --help / -V, --version Usage / version

Shortcut: the gcmd wrapper

The module ships launcher scripts that find the jar next to themselves, so you can drop the java -jar … boilerplate:

cli/gcmd README.md -t github-dark        # Git Bash / WSL / macOS / Linux
cli\gcmd.cmd README.md -t github-dark     # Windows (cmd / PowerShell)

Put the cli/ folder on your PATH (or symlink/copy the matching gcmd script into a directory already on it) to call gcmd from anywhere. The wrappers print a build hint if the jar hasn't been built yet.

The CLI module is standalone (outside the main build), so it never affects the published library artifact.

What renders today

Headings (h1–h6), paragraphs with inline bold / italic / strikethrough / inline code (rendered on a rounded GitHub-style chip) / links (plus bare-URL autolinking and clickable [text](#heading) internal links), ordered & unordered (nested) lists, task lists, syntax-highlighted fenced code blocks, blockquotes, horizontal rules, images, GFM tables (with per-column alignment), footnotes (clickable & bidirectional), 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 resolve in priority order: an inline image from a configured EmojiResolver (the override seam — e.g. ClasspathEmojiResolver over your own <shortcode>.png files); else a crisp colour vector glyph when the optional graph-compose-emoji artifact is on the classpath (the bundled Noto Emoji SVG set — emoji "just work", no images to supply); else readable :shortcode: text in the surrounding style, since PDF fonts carry no emoji glyphs. Opting in to the vector set is one dependency:

<dependency>
    <groupId>io.github.demchaav</groupId>
    <artifactId>graph-compose-emoji</artifactId>
    <version>1.0.0</version>
</dependency>

Geometric emoji typed literally — coloured circles 🔴🟢🟡, squares 🟥🟩, 🔺, diamonds 🔶🔷 and stars — render as native vector shapes in their own colour (no font, no image), instead of a missing-glyph ?.

Headings also become a navigable PDF outline — the viewer's bookmark/outline pane mirrors the document's heading tree and opens automatically when the document has headings (opt out via MarkdownComposer.builder().openOutline(false)) — and each declares a GitHub-style anchor, so [text](#heading) links jump straight to it as native PDF go-to actions (footnote markers jump to their note and back the same way). A standalone [TOC] (or [[_TOC_]]) line expands into an auto-generated, clickable table of contents — one link per heading, nested by level — that you can drop anywhere, including above the headings it lists. Prefer a print-style contents page? Swap in the book TOC — dot leaders and live page numbers, resolved from the laid-out document:

MarkdownTheme book = MarkdownTheme.builder(DefaultMarkdownTheme.light())
        .renderer(TocNode.class, new BookTocRenderer("Contents"))
        .build();

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.

Architecture

MarkdownComposer.render(String)
   │  Flexmark parser (+ GFM tables, task lists, strikethrough, footnotes;
   │                    emoji, autolink, YAML front matter)
   ▼
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.

Theming

A MarkdownTheme is built from three layers, so you change exactly as much as you need and reuse everything else:

  1. Design tokens (MarkdownTokens) — cosmetic values: colors, fonts, sizes, spacing, borders, corner radii, page geometry, syntax-highlight colors.
  2. Component styles (MarkdownStyles) — per-element styles (CodeBlockStyle, ListStyle, QuoteStyle, CalloutStyle, …) derived from tokens; headings use an InlineStyle via headingInline(level).
  3. Node renderers (NodeRenderer) — the behaviour that turns each semantic node into GraphCompose builders, bound to node types in a RendererRegistry.
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();

Ready-made theme packs

Beyond DefaultMarkdownTheme.light() / .dark(), the io.github.demchaav.markdown.theme.packs package ships drop-in themes — the same Markdown, reskinned:

GitHub light
GitHubTheme.light()
GitHub dark
GitHubTheme.dark()
Academic
AcademicTheme.light()
Minimal
MinimalTheme.light()
Business report
BusinessReportTheme.light()
Default light
DefaultMarkdownTheme.light()
MarkdownComposer.create(GitHubTheme.dark()).render(md).writePdf(path);

Full theming guide: docs/theming.md.

Custom renderers — change how Markdown renders

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.

Syntax highlighting

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).

Rich fonts (optional)

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());

Code rendered in JetBrains Mono

The dependency is declared optional, so it only ships if you ask for it.

Bring your own AST

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.)

Examples

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.

Documentation

  • User manualmanual.pdf / manual-dark.pdf (same source, dark theme) — the full guide, written in Markdown and rendered to PDF by the library itself: book-style contents with live page numbers, "Page N of M" footer, vector emoji, smart punctuation — every feature it documents is demonstrated on its own pages.
  • 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, a RendererPack, and custom ::: block types.
  • Changelog — release notes.

Building from source

./mvnw -B -ntp clean verify          # compile + run the full test suite
./mvnw -B -ntp clean verify javadoc:javadoc   # + Javadoc gate

See CONTRIBUTING.md for the branch workflow and commit style.

License

MIT © Artem Demchyshyn

About

A themeable Markdown document composer powered by the GraphCompose layout engine.

Topics

Resources

License

Contributing

Stars

2 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages