Introduction

If you’re working on productivity software, chance is high that somebody will eventually ask you for some reports. Now, often PDF does the job just right, but there are many options to choose from and most packages either give you a lot of power or too little. They do serve their purpose well, but in many cases, a Word document is the right line in-between. Customers also often want to make small changes to the reports your software generates. While tools for changing PDF files exist, they often are not the right fit.

In this guide, I’ll show you how you can setup a pipeline that generates Word documents with localisation, content fetching and some things to look out for. I’m using TypeScript as it gives me a lot more context but you can also follow along using JavaScript too.

Setup

You can follow the complete guide from docxtemplater’s website, but I’ll also give a short introduction: First, you need to install docxtemplater and the accompanying pizzip.

npm i docxtemplater pizzip

Create a new file in your project and import the two packages.

import docxtemplater from 'docxtemplater';
import pizzip from 'pizzip';

Defining some templates

In the end, we want to generate some documents. So let’s take a look at how we’ll define which documents to generate.

We’ll create a file where we’ll store all references to our Word templates. This makes them easy to find and also allows us to do some nifty logic like generating rich filenames and choosing which data to load.

export interface DocumentTemplate {
  source: string;
  name: string;
  provider: ProviderFunction;
  filename: (
    template: DocumentTemplate,
    payload: any,
    translator: Translator
  ) => string;
}

export type ProviderFunction = (
  template: DocumentTemplate,
  payload: any,
  translator: Translator
) => Promise<any>;

export const templates: DocumentTemplate[] = [
  {
    name: 'demo',
    source: 'demo',
    provider: demoProvider,
    filename: (
      template: DocumentTemplate,
      payload: any,
      translator: Translator
    ) => `${template.name} generated by us`,
  },
];

The provider is responsible for fetching the data for our document. Of course, you could always directly pass your data to the generator, too, but especially in more stateless applications like websites, it can be really valuable to have a route that can be called with small amounts of context.

The filename does what it’s supposed to do. We’ll also pass a translator function to localise our filename. Trust me, your customers will ask for this eventually.

Providing data

Let’s take a small look at how we’re modelling our provider. It does prepare all the data the document needs to have. It could also provide validation for us, so that we can catch errors early on.

export async function demoProvider(
  template: DocumentTemplate,
  payload: any,
  translator: Translator
) {
  return {
    name: template.name,
    message: payload.message,
  };
}

I’ve kept the provider simple for this post, but you can imagine pulling some data, preparing things and running validation on inputs or data from the database.

Pulling it together

Now, let’s take a peek at how we’re pulling it together. To outline it better, let’s imagine our generator gets called by a route from a backend:

router.get('/generate/:documentId', async (req, res) => {
  try {
    const generated = await generate(req.params.documentId, req.query);

    res.setHeader(
      'Content-Disposition',
      'attachment; filename="' + encodeURI(generated.filename) + '"'
    );
    res.setHeader(
      'Content-Type',
      'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
    );

    return generated.stream.pipe(res);
  } catch (error) {
    console.error(error);
    if (error.name === 'ValidationError') {
      res
        .status(400)
        .send({ message: 'validation failed', error: error.message });
    } else {
      res.status(500).send({ message: 'Could not generate documents.' });
    }
  }
});

Finally, pull all the parts together. First, we’re fetching the data required for our document. We’re running the generator on our document. Some notes on this: I highly recommend using the “angular-expressions” module as the parser for docxtemplater. Their docs go a bit into more details on how to use it. It provides more complex expressions and is generally more useful for complex documents. You need to install it with npm i angular-expressions. It also allows us to add a pipe so that we can use our translator inside the document. This eliminates the need to use separate templates for different languages.

import docxtemplater from 'docxtemplater';
import pizzip from 'pizzip';
import expressions from 'angular-expressions';
import path from 'path';
import fs from 'fs/promises';
import { Readable } from 'stream';

import { DocumentTemplate, templates, Translator } from './templates';

async function generate(templateName: string, payload: any) {
  const template = templates.find(t => t.name === templateName);
  if (!template) {
    throw new Error(`Template ${templateName} not found`);
  }

  const translator = (key: string, ...args: any[]) => {
    // this is just dummy code, pull in your translator of choice
    return key;
  };

  // get the data for our document
  const providedPayload = await template.provider(
    template,
    payload,
    translator
  );

  // generate the document, as buffer to send it back to the client
  const buf = await makeDocxBuffer(template, payload, translator);
  const stream = Readable.from(buf);

  // generate filename
  const filename = template.filename(template, payload, translator);

  return { stream, filename };
}

async function makeDocxBuffer(
  template: DocumentTemplate,
  payload: any,
  translator: Translator
) {
  const source = path.join('<your location>', template.filename + '.docx');

  const content = await fs.readFile(source, 'binary');

  const zip = new pizzip(content);

  // add translate pipe
  expressions.filters.translate = input => {
    if (!input) return '';
    return translator(input);
  };

  // make the parser parse all the expressions (see docxtemplater docs)
  const angularParser = tag => {
    tag = tag
      .replace(/^\.$/, 'this')
      .replace(/(’|‘)/g, "'")
      .replace(/(„|“)/g, '"')
      .replace(/(“|”)/g, '"');
    const expr = expressions.compile(tag);
    return {
      get: function (scope, context) {
        let obj = {};
        const scopeList = context.scopeList;
        const num = context.num;
        for (let i = 0, len = num + 1; i < len; i++) {
          obj = Object.assign(obj, scopeList[i]);
        }
        return expr(scope, obj);
      },
    };
  };

  const doc = new docxtemplater(zip, {
    parser: angularParser,
    paragraphLoop: true,
    linebreaks: true,
  });

  doc.render(payload);

  const buf = doc.getZip().generate({
    type: 'nodebuffer',
    compression: 'DEFLATE',
  });

  return buf;
}

Conclusion

You should now have a powerful basis to get your templating system running. Of course, you’ll spend the majority of your time fine-tuning the templates and working on other things, but this guide did hopefully speed you up a bit. Let me know what you think!