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!
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:
layout
that it usesmustache
templates to inject the content and metadata from our Markdown filesHere’s what the folder structure looks like:
public/ <- static files and compiled CSSsrc/content/ <- all Markdown fileslayouts/ <- HTML mustache templatesstyles/ <- CSS files (including Tailwind)table-of-contents.json <- Defines order of contentserver.js <- Dev serverindex.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 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.
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
.
It all starts in the index.html
file:
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8" /><link rel="icon" href="/favicon.ico" /><metaname="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 --><scriptsrc="/prince.js"type="text/javascript"></script></body></html>
We have three main things happening here:
body
of the pageThe 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:
index.html
fileindex.html
fileWith 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.
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 Chronicleslayout: chaptersubtitle: Learn all about custom and built-in components.---## Keep Page Component Between RoutesNormally 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 chapterlayout
— the name of the layout file that should be used for this contentsubtitle
— the subtitle of the chapterThe only required one is the layout
property, so the server knows which template to use.
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.
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.
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:
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:
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!
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.
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.