As a developer, you may find yourself embedding word documents in your application or supporting them as part of user uploaded content. Typically, that should be enough. But for something we’re building at NNDI we needed the ability to generate a preview of Word Documents.
On the face of it, this sounds pretty straightforward, get the word document from wherever your system stores it and display it on your user interface. What could be so hard about that?
Well, it turns out it’s not as straightforward. First and foremost, we have to make a decision about how we are going to generate the preview. If you are Google, Microsoft, Proton Docs or Dropbox, you have probably built an engine that supports Word documents and can therefore render the document quite easily, but for our small team building out a whole renderer for Word Documents for a “useful preview” feels like going overboard. We wanted a quick solution that can serve the purpose with decent results at least 80% of the time.
Let’s look at the options we came up with:
Create a Screenshot from an Operating System running Office: Screenshotting from an OS running Office sounds simple until you actually have to run it. We’d need a Windows (or macOS) VM with a licensed copy of Office installed somewhere in our infrastructure, patched, licensed, and do all the plumbing to open a file, wait for rendering, take a screenshot, and tear down for every single preview request. We didn’t want to commit to that many moving parts and licensing cost for something that’s meant to be a lightweight preview feature.
Load/Upload the document onto Office360 or GoogleDocs and take a screenshot: Uploading to Office365 or Google Docs removes the licensing problem but replaces it with a different one. We would be depending on a third-party for a core piece of our infrastructure, and for a simple preview feature. On the security side, it also implied that every document a customer uploads would pass through another vendor’s servers, which is something we want to avoid for as long as we can.
Convert the Word Document to HTML: We tried this with Mammoth.js and even though this approach looked compelling we observed that the results were not ideal for our use case because of issues with formatting and how the output rendered. We wanted something closer to the original document so that our customers wouldn’t get confused or worried about their documents changing structure. Secondly, we didn’t want to have to maintain an additional NodeJS service/codebase just for this.
Converting .docx to PDF via Gotenberg - this would involve converting to PDF via Gotenberg which just wraps LibreOffice in a Docker container. This meant that we had to run a separate Docker container and add PDF preview layer on our User Interface.
What we opted for: Converting .docx to PDF via Gotenberg
After considering the other options listed in the previous section, we decided to use the Gotenberg route. Gotenberg is a Docker-based API built for PDF conversion
Using this approach meant first converting the Word document into a PDF and then rendering that PDF as a preview using the wonderful PDF.js
How to use Gotenberg to convert Word documents to PDF
First and foremost, we need to have gotenberg running, we use their provided Docker image from Dockerhub to setup a service locally. Then we fire off a request, get back the data as PDF and display it on the page or User Interface we want.
Here is a very basic implementation of a Go-based client for gotenberg to do the conversion.
// This package implements a simple API for gotenberg to generate PDFs from Word Documents
package gotenberg
import (
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
const (
LibreOfficeConverterURL = "%s/forms/libreoffice/convert"
)
type Client struct {
URL string
Timeout time.Duration
}
func NewClient(url string, timeout time.Duration) *Client {
return &Client{
URL: strings.TrimSuffix(url, "/"),
Timeout: timeout,
}
}
func (gotenberg *Client) ConvertToPDF(fileName string, content []byte) ([]byte, error) {
header := make(http.Header)
var buf bytes.Buffer
w := io.MultiWriter(&buf)
mw := multipart.NewWriter(w)
fw, err := mw.CreateFormFile("file", fileName)
if err != nil {
return nil, err
}
_, err = fw.Write(content)
if err != nil {
return nil, err
}
err = mw.Close()
if err != nil {
return nil, err
}
header.Set("Content-Type", mw.FormDataContentType())
client := &http.Client{
Timeout: time.Duration(gotenberg.Timeout),
}
req, err := http.NewRequest("POST", fmt.Sprintf(LibreOfficeConverterURL, gotenberg.URL), &buf)
if err != nil {
return nil, err
}
req.Header = header
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
return data, nil
}
func (gotenberg *Client) ConvertToPDFs(files []string) ([]byte, error) {
header := make(http.Header)
var buf bytes.Buffer
w := io.MultiWriter(&buf)
mw := multipart.NewWriter(w)
for _, file := range files {
fileName := filepath.Base(file)
// Strip .docx extension so Gotenberg returns filename.pdf instead of filename.docx.pdf
fileName = strings.TrimSuffix(fileName, ".docx")
fw, err := mw.CreateFormFile("files", fileName)
if err != nil {
return nil, err
}
content, err := os.ReadFile(file)
if err != nil {
return nil, err
}
_, err = fw.Write(content)
if err != nil {
return nil, err
}
}
err := mw.Close()
if err != nil {
return nil, err
}
header.Set("Content-Type", mw.FormDataContentType())
client := &http.Client{
Timeout: time.Duration(gotenberg.Timeout),
}
req, err := http.NewRequest("POST", fmt.Sprintf(LibreOfficeConverterURL, gotenberg.URL), &buf)
if err != nil {
return nil, err
}
req.Header = header
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
return data, nil
}
You would use it as follows:
package main
import (
"os"
"time"
"./gotenberg" // adjust to your actual import path for the script above
)
func main() {
client := gotenberg.NewClient("http://localhost:3000", time.Duration(30*time.Second))
filename := "./path/to/document.docx"
docxData, err := os.ReadFile(filename)
if err != nil {
panic(err)
}
pdfData, err := client.ConvertToPDF("mydocument", docxData)
if err != nil {
panic(err)
}
err = os.WriteFile("./path/to/output.pdf", pdfData, 0o664)
if err != nil {
panic(err)
}
}
You can then pass this to your frontend layer and render as a PDF.
Conclusion
Generating “good enough” previews of Word documents doesn’t require building a renderer from scratch. By converting to PDF with Gotenberg and rendering that PDF with PDF.js, we got a preview that’s visually close to the original document, without maintaining a second codebase or depending on a third-party product we don’t control.
It’s not a perfect solution — conversion isn’t instant, and LibreOffice’s rendering isn’t always pixel-identical to Word’s — but it clears our 80% bar with a fraction of the complexity of the alternatives, and it’s held up well in production so far.