A Big Markdown File Should Become a PDF You Can Navigate | Artem DemchyshynA Big Markdown File Should Become a PDF You Can Navigate<br>Convert a long Markdown document to PDF and it becomes something you move around in: a clickable bookmark outline, working [text](#heading) jumps, a page-numbered contents, bidirectional footnotes. Why that's the part most converters skip, and how it all falls out of a single idea — name an anchor now, let the engine bind it to a page after layout.<br>4 July 2026 · 9 min · Artem Demchyshyn<br>Markdown becomes a PDF you can move around in — bookmarks, TOC, internal links, and footnotes.Table of ContentsWhy converters skip this<br>The mapping is (almost) one line per heading<br>The same move, four more times<br>Why this lives below the converter, not in it<br>The payoff
Update (July 2026). The first version of this post told one story — headings<br>become a PDF bookmark outline. That’s still the heart of it, but the library grew<br>a whole navigation layer since, and it just shipped to Maven Central as<br>io.github.demchaav:graph-compose-markdown:0.3.0. So here’s the fuller picture:<br>the outline, plus working [text](#heading) jumps, an auto-generated [TOC],<br>a page-numbered book-style contents, and bidirectional footnotes — all the same<br>idea applied five times.
Take a long Markdown file — a spec, a README that grew too big, a design doc<br>with thirty sections. Convert it to PDF the usual way and you get a wall of<br>pages. To find the deployment section you scroll, or you Ctrl+F, or you guess<br>at a page number. The structure that was right there in your headings —<br>#, ##, ### — is gone. It rendered as bigger, bolder text and nothing<br>else.<br>It shouldn’t be gone. Those headings are the table of contents. A PDF can<br>carry a bookmark outline that the viewer shows in a side panel, nested exactly<br>like your headings, every entry a clickable jump. Open the panel, click<br>“Deployment,” you’re there. And that’s only the start of what a long document<br>should give you: a [TOC] you can click, cross-references ([see deployment](#deployment))<br>that actually jump, footnotes that take you down to the note and back up. That’s<br>the stuff almost every Markdown-to-PDF path drops on the floor.<br>I wanted my converter to keep all of it. The repo is<br>graphcompose-markdown: it<br>parses Markdown with Flexmark, maps it to its own semantic model, and renders<br>that through the GraphCompose engine<br>to a PDF. The headline is simple to state — a long document becomes one you can<br>move around in — and the interesting part is how little code each piece took,<br>because they’re all the same trick.<br>Why converters skip this#<br>A PDF bookmark is not a heading. It’s an entry in a separate document outline<br>tree that lives outside the page content — a node with a title and a<br>destination (a page object plus coordinates), arranged in its own hierarchy<br>that the viewer renders in the bookmarks panel. The same is true of an internal<br>link: it’s a GoTo action pointing at a named destination, nothing to do with<br>the text that triggers it.<br>So to turn a heading into a bookmark — or a [text](#heading) into a real jump —<br>you need three things a naive converter doesn’t have lying around at the right<br>moment:<br>The plain text of the heading. A heading like ## **Deploy** the service<br>carries bold and code spans. The outline title has to read Deploy the service<br>— markup stripped — or the panel shows raw asterisks and backticks.<br>The page and position the heading landed on. You don’t know that until<br>the document is laid out and paginated, which happens long after you’ve<br>walked the Markdown.<br>A stable name, and a nesting level. An h3 under an h2 under an h1<br>has to nest as a tree. And every heading needs an anchor — the same one a<br>[link](#anchor) and a [TOC] entry will target — or nothing resolves.<br>Most “md to pdf” tools render visually correct pages and stop. Everything above<br>is extra plumbing against the PDF’s structure, decoupled from the text flow, and<br>it’s easy to decide it’s not worth it. That’s the gap I cared about closing.<br>The mapping is (almost) one line per heading#<br>The trick is that the layout engine already solves the hard parts. GraphCompose<br>owns measurement and pagination, and it supports a declarative bookmark on a<br>container that resolves to the right page after layout — the author never<br>touches coordinates. It also lets a paragraph declare a named anchor that<br>other content can jump to. So the Markdown renderer only does the cheap part:<br>pull the plain text, hand the heading level through as the outline depth, and<br>name the anchor.<br>Here’s the actual heading renderer, lightly trimmed to the navigation lines:<br>public void render(HeadingNode node, SectionBuilder host, RenderContext ctx) {<br>RichText rich = ctx.toRich(node.content(), ctx.headingInline(node.level()));<br>String title = ctx.inline().plainText(node.content()).strip(); // markup stripped
// The slug this...