Engineering

Using Vue to generate PDFs

Headshot, Allan Almazan
By Allan Almazan

Learn how to generate custom-styled PDFs using Vue. Extend the approach here to use any technologies that generate vanilla HTML and CSS.

Arrow iconBack to all articles
Learn how to generate custom-styled PDFs using Vue

In a previous post we described how to use React and styled-components to generate PDFs. This post will explain how to generate HTML and CSS from a Vue for use in Anvil’s PDF generation endpoint. At the end you should be able to generate an invoice like this with Vue components:

Invoice example

We also have a GitHub repository available with the code below if you’d like full working examples. All you need to do is set your Anvil API key and provide your own components.

With Vue.js (or just plain Vue) one of the key features is using the framework’s single-file components (or SFCs) support for all your components. This can lead to cleaner, modularized components with all its related code (template, Javascript and styling) in one file. Vue also supports JSX components (the syntax commonly used with React) and a few other types of components.

For the purposes of using Anvil’s PDF generation endpoint, SFCs sound perfect. We can have one main component that composes a PDF page (or multiple pages) with dynamic data and custom CSS/SCSS.

Due to the complexity of compiling SFCs, this can get complicated fast. This post will outline two ways to get your Vue components ready to use in Anvil’s PDF generation endpoint: SFC-style components and plain text components. Let’s get started.

Anvil API script setup

First, we will need to create a script that will call the Anvil PDF generation endpoint and provide it with HTML and CSS. These steps are shown in the previous post under “Set up the scaffolding”.

Once we’re able to confirm that the example script calls the API properly and we get a valid PDF at the end we can move on.

Minimum Vue project setup

If your component structure is really minimal, and you’d like to use some of Vue’s features without SFCs, we can do the following. Below is roughly the same as what the Vue documentation recommends in its SSR section.

  1. Create a new directory and cd into it
  2. Run npm init -y
  3. Add "type": "module" in package.json so that Node.js runs in ES modules mode
  4. Run npm install vue (or yarn add vue)
  5. Create an example.js file:
// this runs in Node.js on the server.
import { createSSRApp } from 'vue'
// Vue's server-rendering API is exposed under `vue/server-renderer`.
import { renderToString } from 'vue/server-renderer'

// We can create other components using the "Options API" style.
// https://vuejs.org/guide/introduction.html#api-styles
const OtherComponent = {
  data: () => ({ text: 'Hello' }),
  template: `
    <div>
    <p>This is within the imported component.</p>
    <p>{{ text }}</p>
    </div>
  `
}

const app = createSSRApp({
  //
  components: { OtherComponent },
  data: () => ({ count: 1 }),
  computed: {
    countPlusTwo: (state) => state.count + 2
  },
  template: `
    <div>
    <OtherComponent />
    <h2>Hello from the main Vue component</h2>
    <p>This also uses data. Count is: {{ count }}.</p>
    <p>This uses computed data. Count plus 2 is: {{ countPlusTwo }}</p>
    </div>
  `
})

// This render function is mainly used to `console.log` out if it works properly.
// Once confirmed, you can remove this. We will work with the `app` instance
// directly in the PDF generation script. 
renderToString(app).then((html) => {
  console.log(html)
})

export default app

Running the above should give you:

$ node example.js

<div><div><p>This is within the imported component.</p><p>Hello</p></div><h2>Hello from the main Vue component</h2><p>This also uses data. Count is: 1.</p><p>This uses computed data. Count plus 2 is: 3</p></div>

What does the code do?

  1. We first run createSSRApp to create an app instance in hydration mode. This gets the app instance ready and pre-renders all related components.
  2. We then use renderToString to render the entire app down to its HTML.

We can further customize this by changing what we pass to createSSRApp. The object passed to the function follows the “Options API” component style. This gives you most of the functionality provided by Vue. With this method, however, you need to maintain and read in your CSS styles manually before we call the PDF generation API.

Minimum Vue project with the Anvil PDF generator

Now let’s look at the PDF generation script again with our minimal Vue app:

import fs from 'fs'
import Anvil from '@anvilco/anvil'
// This is our app using `createSSRApp`
import app from './app.js'
// The main function that gives us our HTML
import { renderToString } from 'vue/server-renderer'

const apiKey = ''

// Read a separate CSS file to style the rendered HTML.
async function getCss() {
  return (await fs.readFileSync('./main.css')).toString()
}

async function buildHTMLToPDFPayload () {
  // This returns a Promise. Remember to `await`
  const html = await renderToString(app)
  const css = await getCss()
  return {
    data: {
      html,
      css,
    },
  }
}

async function main () {
  const client = new Anvil({ apiKey })
  // This is now an async function, so we need to `await` it.
  const exampleData = await buildHTMLToPDFPayload()

  const { statusCode, data, errors } = await client.generatePDF(exampleData)

  if (statusCode === 200) {
    fs.writeFileSync('output.pdf', data, { encoding: null })
  } else {
    console.log(statusCode, JSON.stringify(errors || data, null, 2))
  }
}

main()

Minimum Vue project result:

Minimum Vue app PDF result

Run the script. If this is enough for you to create your own HTML templates, you can stop here. That was simple enough right? If you use SFCs and/or just want to see what’s involved to get Vue’s full functionality working, read on!

Generating HTML from Vue SFCs

Most Vue apps use SFCs, so the above is a good start, but if you have an existing set of components, converting them all manually is not an attractive option. There must be a way to get HTML from SFCs.

Fortunately, there is a way, but due to the complexity of Vue’s SFC format, we will need to go through a few intermediate steps to compile and parse those files into plain Javascript and CSS, then output plain HTML and CSS.

In addition to what we’ve done above, we will also need Vite. Vite is similar to tools like webpack and can compile projects, such as Vue projects, through a variety of plugins. It does a lot of other cool things too, so check out their “Why Vite” if you’re interested. For our purposes, it can compile Vue projects and deal with CSS quickly and efficiently and with little overhead.

Vue SFCs to HTML and CSS

We’ll start off with similar steps as the minimal project:

  1. Create a new directory and cd into it
  2. Run npm init -y
  3. Add "type": "module" in package.json so that Node.js runs in ES modules mode.
  4. Run npm install vue @anvilco/anvil (or yarn add vue @anvilco/anvil)
  5. Run npm install -D vite @vitejs/plugin-vue (or yarn add -D vite @vitejs/plugin-vue)

Now let’s create some basic Vue components. We will have a project structure of:

├── src
│   ├── components
│   │   └── HelloWorld.vue
│   └── App.vue
├── package.json
└── package-lock.json
// App.vue
<script>
import HelloWorld from '../components/HelloWorld.vue'

export default {
  name: 'App',
  components: {
    HelloWorld,
  }
}
</script>

<template>
  <div>Hello from Vue + Vite</div>
  <HelloWorld/>
</template>

<style >
body {
  color: blue;
}
</style>

// HelloWorld.vue
<template>
  <div>
    <h4>Within the hello world component</h4>
    <p>Hello world. This is from a .vue SFC with styles.</p>
  </div>
</template>


<script setup>
  // This uses <script setup> which reduces boilerplate.
  // Since this is a very simple component this is all we need.
</script>


<style >
p {
  color: red;
}
</style>

We also need to let vite know that we are using Vue, as well as a few other options that will be useful later. Place this file in the base of your project directory as vite.config.js (or .ts if you want to use Typescript):

// project-dir/vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],

  build: {
    minify: false,
    // Do not empty out the dir since we need the resulting `.css` files from
    // a previous build.
    emptyOutDir: false,
    rollupOptions: {
      output: {
        // Do not prefix compiled js and asset files.
        // This helps with grabbing the css files directly with known filenames.
        entryFileNames: `[name].js`,
        chunkFileNames: `[name].js`,
        assetFileNames: `[name].[ext]`
      }
    }
  },
})

Now we can create our SSR app and attempt to render it to a string like the minimal version. This is:

// project-dir/src/ssr-entry.js

import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
import App from './App.vue'

export async function render () {
  const app = createSSRApp(App)
  return renderToString(app)
}

In the project root directory, we can check if everything compiles fine so far:

$ npx vite build --ssr src/ssr-entry.js
vite v3.1.7 building SSR bundle for production...
✓ 6 modules transformed.
dist/ssr-entry.js   1.83 KiB

If you get different output, like parse errors on your .vue files, double-check your vite.config.js and make sure it matches the one above. vite.config.js must also be on the project root directory.

Now we can wire that up into our PDF generation script from before. Note the location of the file. This will be used later.

// project-dir/src/generate.js

import fs from 'fs'
import Anvil from '@anvilco/anvil'
// This is the app and its components precompiled. 
import { render } from '../dist/ssr-entry.js'

const apiKey = 'YOUR_API_KEY'

async function getCss() {
  return (await fs.readFileSync('dist/index.css')).toString()
}

async function buildHTMLToPDFPayload () {
  // This returns a Promise.
  const html = await render()
  // We still need to read in a CSS file, but this is extracted from our Vue
  // SFCs and output to the dist directory.

  // This is commented out for now. We’re not there yet!
  // const css = await getCss()
  return {
    data: {
      html,
      css: '',
    },
  }
}

async function main () {
  const client = new Anvil({ apiKey })
  const exampleData = await buildHTMLToPDFPayload()

  const { statusCode, data, errors } = await client.generatePDF(exampleData)
  // ...
}

Most of this looks the same, but the key difference is this line:

import { render } from '../dist/ssr-entry.js'

After using vite to build the app, we’ll be using the compiled ssr-entry.js file’s render function. If you’re curious, you can even check the dist/ssr-entry.js file yourself and notice that all the components we’ve used so far have all been extracted/compiled into that file.

Almost there?!

To recap, we have:

  • vite installed and set to process .vue files.
  • .vue SFC components
  • an src/ssr-entry.js file to create the SSR app instance and render it to a string
  • PDF generate script that uses a compiled ssr-entry.js for its HTML output

What’s missing? CSS.

Unfortunately, this step is kind of a hack as there didn’t seem to be a way to extract CSS from the SSR render process. What we will do is add another build step as if we were building a true Vue app.

To do this, we need both an index.html and a main.js file. They’re very basic and don’t do too much, since we don’t need it to:

<!DOCTYPE html>
<html lang="en">
<head>
  <title>Vite + Vue</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')

Let’s test out that build step:

$ npx vite build
vite v3.1.7 building for production...
✓ 13 modules transformed.
dist/index.html   0.22 KiB
dist/index.css    0.04 KiB / gzip: 0.05 KiB
dist/index.js     141.37 KiB / gzip: 31.85 KiB

Perfect. We now have a CSS file. Tying everything together, we’ll add this script to our package.json file:

{
  "scripts": {
    "generate": "vite build && vite build --ssr src/ssr-entry.js && node src/generate.js"
  }
}

Uncomment this line from the generate script:

  // This is commented out for now. We’re not there yet!
  const css = await getCss()

And to run, we will use npm run generate:

$ npm run generate

> full-sfc-support@1.0.0 generate
> vite build && vite build --ssr src/ssr-entry.js && node src/generate.js

vite v3.1.7 building for production...
✓ 13 modules transformed.
dist/index.html   0.22 KiB
dist/index.css    0.04 KiB / gzip: 0.05 KiB
dist/index.js     141.37 KiB / gzip: 31.85 KiB
vite v3.1.7 building SSR bundle for production...
✓ 6 modules transformed.
dist/ssr-entry.js   1.83 KiB

And our final result:

Final PDF result with Vue SFCs

Summary

You should now be able to create PDFs with Vue with two different approaches. Compared to the previous React post, using Vue is more complicated to set up, but it may be worth the effort if you prefer Vue’s ecosystem and SFC-style components.

As mentioned earlier, all the code above is available in a repository here where all you need to provide is your own Anvil API key and Vue components.

One final note: There is a caveat with using Vue SFC’s CSS styles. Anvil’s HTML to PDF endpoint currently does not support Vue’s scoped style syntax (i.e. Vue components with <style scoped>). If you use those, consider using other selector combinations that only affect your component’s scope.

If you have questions, or you're developing something cool with PDFs, let us know at developers@useanvil.com. We’d love to hear from you.