Create Beautiful PDFs with HTML, CSS, and Markdown

I built an easy-to-use tool that lets me use just HTML, CSS, and Markdown to create beautiful ebooks and PDFs.

I wasted thousands of dollars hiring someone to format and layout a previous book, but it was a painful process. Industry standard tools like Adobe InDesign don’t work well with Markdown, and getting the syntax highlighting right was a complete nightmare — I spent weeks and many iterations of reviews trying to get it right.

But my custom tool lets me do all of that in just a day or two, and it’s a breeze to use (it even has hot reloading, so I can see changes immediately).

I can use the skills I’ve spent a decade mastering — HTML, CSS, Javascript, and Markdown. It also lets me reuse my existing branding and styles from my blog and course platform.

In this article I'll show you exactly how it works.

Here’s what we’ll cover:

Let's get on with it!

Introduction to Paperback: My Custom PDF Tool

Paperback takes neatly organized Markdown content, HTML layouts, and CSS, and combines them into a single HTML page. Then, it converts that page to a PDF using a tool called Prince.

It has these parts:

  1. Content — Write content in Markdown with frontmatter to add metadata and control the layout that it uses
  2. Layout — HTML files that use mustache templates to inject the content and metadata from our Markdown files
  3. Styles — CSS that defines how each layout should look
  4. Javascript — A script that generates the table of contents dynamically
  5. Server — Pulls everything together and serves the HTML page
  6. Commands — A few commands to run the server, process CSS, and generate the PDF — with support for hot reloading

Here’s what the folder structure looks like:

public/ <- static files and compiled CSS
src/
content/ <- all Markdown files
layouts/ <- HTML mustache templates
styles/ <- CSS files (including Tailwind)
table-of-contents.json <- Defines order of content
server.js <- Dev server
index.html <- Base HTML file

Paperback also lets you use the browser as a dev tool, so you can inspect the DOM and debug your styles, since you can’t exactly do that with a PDF.

The Power of Prince: Transforming Web Pages into PDFs

The core of this tool is a special “web browser” called Prince, whose development is led by the creator of CSS himself.

But instead of browsing the web, it converts web pages into PDFs. What makes it special, is that it has really good support of the CSS paged media module, which lets us define page breaks, pages, page regions, and other things we need to do with books.

It’s also been around for twenty years, so it’s a very stable and feature-rich tool.

Running Paperback: Package Scripts

To get everything running, I have a pdf:all command, which compiles CSS, serves the HTML, and transforms it into a PDF with hot reloading. It uses the concurrently package to run a few things in parallel: concurrently 'npm:postcss' 'npm:dev' 'npm:pdf:watch'.

If I open the PDF with Preview on macOS, I just need to bring focus back to Preview after a change in order for it to reload the updated PDF.

Here's a breakdown of the individual commands:

npm:postcss

The first is the PostCSS command: postcss ./src/styles/index.css -o ./public/output.css -w. This will re-compile our CSS every time it changes.

npm:dev

Second, we have the command that runs the server which serves the page at localhost:3000: node server.

npm:pdf:watch

Lastly, we have the command that pulls it all together to produce the PDF using Prince: prince http://localhost:3000/ -j -o test.pdf. This gets Prince to render the HTML document found at localhost:3000, writing to the file test.pdf. Adding -j enables Javascript so we can generate the table of contents.

This Prince command is run using npm-watch to automatically re-run whenever any source files are changed. I use these settings in my package.json to set that up:

// package.json
{
"watch": {
"pdf": {
"patterns": [
"public",
"src"
],
"extensions": "css,md,html"
}
}
}

This tells npm-watch to re-run the pdf script whenever a CSS, Markdown, or HTML file changes inside of the public or src folders. We include public so that it will be triggered when PostCSS is finished compiling the CSS file.

Then, set up the scripts themselves:

// package.json
{
"scripts": {
"pdf": "prince http://localhost:3000/ -j -o test.pdf",
"pdf:watch": "npm-watch",
}
}

We set up the pdf script to call the Prince CLI, and the pdf:watch script to call npm-watch.

Serving the Content as an HTML Page

It all starts in the index.html file:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<!-- 👇 1. CSS loaded here -->
<link rel="stylesheet" href="/output.css" />
</head>
<body class="content" id="content">
<!-- 👇 2. HTML content injected here -->
<!--index-->
<!-- 👇 3. Script loaded here -->
<script
src="/prince.js"
type="text/javascript"
></script>
</body>
</html>

We have three main things happening here:

  1. Our compiled CSS is loaded in
  2. All the Markdown content, now transformed into HTML, is added to the body of the page
  3. Our prince script is loaded to generate the table of contents (and do anything else we’d like)

The CSS is compiled using the npm:postcss command we saw earlier: postcss ./src/styles/index.css -o ./public/output.css -w.

Exactly how that CSS is written using the Paged Media module is really interesting, so I’ve included some examples at the end of the article for you.

But for now, let’s see how the server works.

The entire book gets built into an HTML page on every request. We do this on every single request so that we can guarantee freshness, in order to make the hot reloading work.

We have a few steps in this process:

  1. Load the index.html file
  2. Load all of the Markdown content and parse the frontmatter
  3. Load all of the templates
  4. For each content file, render the Markdown to HTML, using the correct layout file to produce the final HTML
  5. Combine all the content HTML together, and inject into the index.html file

With this server, we now have our fully constructed page being served at localhost:3000. Visiting this page in the browser is a great way to debug the styles, since we can use the built in devtools and see how the Javascript is being run:

No, it doesn't look nice in the browser, but it does look nice in the PDF.

So, now we have our HTML.

But we still need to turn it into a PDF.

Writing Content in Markdown for eBooks

All the content is written in Markdown, which is what I prefer to write in (along with most devs I’d imagine). Here’s a snippet of the first chapter of Nuxt Tips Collection:

---
title: Component Chronicles
layout: chapter
subtitle: Learn all about custom and built-in components.
---
## Keep Page Component Between Routes
Normally when a page change happens, the components are destroyed and recreated. We can keep a component “running” by using the `keepalive` property, so Nuxt will wrap it in the `<KeepAlive>` component for us.
Let’s say we have this page that continually counts up:
```html
// ~/pages/count.vue
<template>
{{ count }}
</template>
<script setup>
const count = ref(0);
onMounted(() => {
setInterval(() => count.value++, 1000);
});
</script>
...

Every time we navigate away from and back to /count, this component is re-created, resetting our count back to zero each time.

We have a few properties in the frontmatter here:

  • title — the title of the chapter
  • layout — the name of the layout file that should be used for this content
  • subtitle — the subtitle of the chapter

The only required one is the layout property, so the server knows which template to use.

Designing Layouts with Mustache Templates

Each layout is an HTML file that uses mustache to insert variables into a template. Here’s an example of the chapter layout:

<div class="chapter">
<div class="chapter-title">
<h3></h3>
<h1>{{ &title }}</h1>
</div>
<p>{{ &subtitle }}</p>
<img class="fullscreen" src="./chapterbg.svg" />
</div>
<div class="tips">{{ &content }}</div>

The double curly braces let us put variables in — the title, subtitle, and content from our Markdown files. The call to mustache happens in our server file and looks like this:

mustache.render(
`<div>{{ someVariable }}</div>`, // The mustache template
{
someVariable: 'Hello World!', // An object of variables
}
);

This is the output we’d get from that:

<div>Hello World!</div>

The & prefix means we’ll treat that value as raw HTML. This lets us put rendered Markdown into these variables. Our server takes care of that, processing all the variables as Markdown into HTML before stitching them into the layouts.

In this case, for the chapter layout, we have two main divs, one for the chapter’s title page and one for the content of the chapter. This is so that we can use the special @page at-rule to have the title page treated as a full separate page, even though it doesn’t have very much content on it.

When Prince goes from the .chapter div to the .tips div, it recognizes it’s a new page (because of the @page at-rule in our CSS), and begins a new page. We’ll see another example of this at-rule at the end of the article.

Dynamically Generating the Table of Contents with a Custom Script

One cool feature that Prince has is that we can get it to run a script after the PDF has been rendered. We can discover what pages different elements are on, and then use that info to dynamically generate a table of contents, an index, or re-size elements if we need to.

I’m using it in Paperback to generate a table of contents based on the content included.

This script needs to call Prince.registerPostLayoutFunc and pass it the function that will be called once Prince is done rendering.

Since we’re dealing with vanilla Javascript, the function for generating the table of contents looks something like this, with lots of DOM querying and manipulation:

// ...
tocChapterName = document.createElement('h3');
tocChapterName.appendChild(chapterNameNode);
tocChapterLink = document.createElement('a');
tocChapterLink.href = '#' + getSlug(chapterName);
tocChapterLink.appendChild(tocChapterName);
tocChapterDescription = document.createElement('p');
tocChapterDescription.appendChild(chapterDescriptionNode);
// ...

Prince implements a subset of Javascript, but the most recent version supports most ES6 so we can write Javascript in a familiar way.

Styling Your PDF: Examples and Techniques

The CSS Paged Media module has some interesting additions, and Prince is one of the only user-agents that actually implements these, plus a few really useful things that aren’t in the CSS spec. This is why it’s so useful as a PDF generation tool.

Here are a couple examples of what we can do:

  • Adding a Footer with Page Numbers and Titles
  • Generating a Table of Contents with CSS

/articles/create-beautiful-pdfs-with-html-css-and-markdown/withfooter.png

The main part of the Paged Media module is the @page at-rule. Using this we can set margins, as well as control what happens in different page regions (aka margin at-rules).

Here’s a diagram of these margin at-rules from Prince’s documentation:

marginboxes-1.colour.png

Aside: MDN says they haven’t been implemented by any user-agent, but this is technically wrong since Prince definitely implements them!

For example, this is what the @page looks like for creating this footer:

@page {
size: A4 portrait;
margin: 1.5cm 2cm 3cm 2cm;
color: var(--type-dark);
@bottom-right-corner {
border-top-color: var(--coral);
border-top-width: 2px;
border-top-style: solid;
margin-top: 50%;
margin-left: 0.3cm;
content: '';
}
@bottom-right {
width: 0.5cm;
font-size: 11px;
content: counter(page);
}
@bottom {
text-align: right;
padding-right: 1cm;
content: 'Nuxt Tips Collection';
font-size: 11px;
text-transform: uppercase;
font-weight: bold;
}
};

Let me break this down for you section by section.

@page {
size: A4 portrait;
margin: 1.5cm 2cm 3cm 2cm;
color: var(--type-dark);
}

Here, we set the size of the page to A4 in the portrait orientation. We then use centimetres to set the margin — physical measurements just make more sense since we’re dealing with paged media here, even if this will never get printed. Finally, we set the text colour using a CSS var I’ve defined elsewhere.

@bottom-right-corner {
border-top-color: var(--coral);
border-top-width: 2px;
border-top-style: solid;
margin-top: 50%;
margin-left: 0.3cm;
content: '';
}

We’re now defining the bottom-right-corner section of the page. This corner sits entirely in the margin of the page.

I’m using content: '' to ensure it’s rendered. Then, using border and margin-top properties, I render a line that’s centred vertically in the region. The margin-left is there so it doesn’t get too close to the page number.

The next section renders the page number:

@bottom-right {
width: 0.5cm;
font-size: 11px;
content: counter(page);
}

We use a CSS counter to render the page number as the content for this region. I’m also adjusting the width and font size to keep it positioned nicely.

I also want to print the name of the book in the footer:

@bottom {
text-align: right;
padding-right: 1cm;
content: 'Nuxt Tips Collection';
font-size: 11px;
text-transform: uppercase;
font-weight: bold;
}

We align the text to the right, since this bottom region spans the width of the page (minus the left and right margin). Throw on a uppercase text transform, a bold font weight, and a little right padding, and it’s looking great!

Generating a Table of Contents with CSS

/articles/create-beautiful-pdfs-with-html-css-and-markdown/leaders.png

Only half of the table of contents is generated using a script. The other half is just CSS.

The “leader” dots and the page number are CSS, but using features that you’ve likely never come across because they aren’t implemented in most browsers:

#toc ul a:after {
content: leader(' .') target-counter(attr(href), page);
}

We use the leader CSS function to fill the content of this after pseudo-element with dots. It will repeat as many times as needed to fill the space.

But at the end we want to stick the page number. We get that using the target-counter function. We pass it a URL and a counter, and it will give us the value of the counter at the links location.

In the case of Nuxt Tips Collection, each tip in the book has a URL in the form of an id, courtesy of the custom script that we run after the first layout pass:

<h2 id="tip-2">DevOnly Component</h2>

The link to this tip in the table of contents is rendered like this:

<a href="#tip-2">DevOnly Component</a>

So the value of attr(href) here is #tip-2. Then, the value of target-counter(#tip-2, page) is the page number that tip 2 is on!

By the way, the attr function has great browser compatibility, but only works in the content property.

The Prince documentation has more on using generated content in your PDFs.

Wrapping Up

In this article, I've shared my journey of building Paperback, a custom tool that streamlines the process of creating beautiful ebooks and PDFs using HTML, CSS, and Markdown.

By leveraging familiar web technologies and the powerful Prince tool for PDF generation, Paperback offers a seamless, hot-reloading workflow that integrates perfectly with my existing skills and branding.

I hope this detailed breakdown inspires you to simplify your own content creation process, making it more enjoyable!

Also, Nuxt Tips Collection is launching this coming Monday, August 5th! It’s a collection of 117 concise and insightful tips on using Nuxt better. You can check it out here.

Get my FREE book on Vue 3!

Vue 3 Book