Elegant PDFs from User-Generated Content in React

Chad Wilken
CompanyDev
Published in
9 min readMar 3, 2020
Photo by Karsten Würth on Unsplash

Converting user generated HTML into a PDF has always been a pain. You’re stuck with solutions like wkhtmltopdf, PhantomJS, or Headless Chrome. The problem I have found with all of these tools is that they are either slow, fail to match styles, or require running on a server. At CompanyCam we allow our users to generate reports, some of which are hundreds of pages long. No matter how you slice it, that makes any of the options above much more difficult, especially if you allow the user to preview their PDF.

In this post we will look at how we use a combination of React PDF, DraftJS, and Redraft to craft a PDF from user-generated content that is instantly available in the user’s browser for preview and download.

Since we use several libraries I think it may be helpful to give a quick overview and what each library is used for.

React PDF

React PDF is a PDF renderer built for React. It includes several components that represent different aspects of a PDF document such as a Document, Page, and View. It can output to a file on the server or a Blob in the browser. It provides an interface for layout, styling, and asset fetching.

DraftJS

DraftJS describes itself as a “Rich Text Editor Framework for React”. Draft was developed by Facebook and is extremely flexible and extensible.

Redraft

[Redraft] is a middleman of sorts and renders the output from DraftJS, allowing you to override the default elements that correspond with different blocks.

React-PDF

No, this isn’t a duplicate. Confusingly, it has a similar name, but it a completely different library. React-PDF is a library to display PDFs on the web as an svg or canvas. As a tip, this library doesn't have a "@" in the name.

The Flow

CompanyCam allows users to build reports using photos they’ve captured in our app. Users can add comments using a WYSIWYG editor and optionally choose if they want to display metadata such as when the photo was captured, who took the photo, and the project it belongs to.

When the user selects to generate a PDF of a report we launch a modal and finish fetching the pages of entries. Once we have the data we can get down to work. We pass the report to our PDF component, which is responsible for bootstrapping some libraries, generating a PDF and converting it to a blob, and then rendering the preview.

We start by passing our Document component to the pdf function imported from @react-pdf/renderer. The Document component will be shown later, for now know that it uses @react-pdf components to represent entires in the report.

import { pdf } from ‘@react-pdf/renderer’;Const PDF = ({ report }) => {
const [loading, setLoading] = useState(true);
const [documentURL, setDocumentURL] = useState();
Const [numPages, setNumPages] = useState(0);
useEffect(
() => {
const generateBlob = async () => {
setLoading(true);
const blob = await pdf(
<Document report={report} imageSize={imageSize} />,
).toBlob();
setLoading(false);
setDocumentURL(window.URL.createObjectURL(blob));
};
if (report) {
generateBlob();
} else {
setLoading(false);
setDocumentURL(null);
}
},
[report],
);
};

We await the pdf function and then call toBlob to get a Blob object. We generate a URL for the blob using URL.createObjectURL and then store that in the state. We can use the URL of the blob to render a preview of the document and to allow the user to download the file.

import { pdfjs, Document as PDFDocument, Page as PDFPage } from ‘react-pdf’;pdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.js`;const PDF = ({ report }) => {
// PDF generation omitted here.
if (loading) {
return <Spinner message="Fetching Images" />;
}
return (
<React.Fragment>
{loading && <div>Rendering PDF…</div>}
<div>
<a
href={documentURL}
className="ccb-blue-small"
style={{ margin: ‘auto 0 0’ }}
download={filename}
>
Download PDF
</a>
</div>
<PDFDocument
file={documentURL}
onLoadSuccess={(result) => setNumPages(result.numPages)}
loading={<Spinner />}
>
<PDFPage
renderMode="svg"
pageNumber={currentPage}
/>
</PDFDocument>
</React.Fragment>
);
}

Once we have the documentURL generated by @react-pdf we can render a preview using react-pdf. We pass the PDFDocument component the documentURL as the file. Once the document has loaded it will call onLoadSuccess and tell us some info such as how many pages the PDF contains. Inside of that, we pass a PDFPage component and tell it to either render using a canvas or svg and which page we want to display.

You will notice that we can pass the documentURL as the href and a filename to the download prop for the a tag and it will download the blob with the filename specified. This is handy because the user can instantly download the generated PDF.

I omitted the code that shows how we change the page for brevity. The code is a simple click handler that increments or decrements the currentPage variable making sure it stay in bounds.

At this point we know how to render and display the Document component, but what does Document do and how do we convert HTML to @react-pdf components?

Document Rendering and Content Conversion

React-PDF handles almost everything for you out of the box so the Document component is fairly simple.

import {
Document as PDFDocument,
StyleSheet,
Page,
Font,
} from ‘@react-pdf/renderer’;
import PageFooter from ‘./PageFooter’;
import CoverPage from ‘./CoverPage’;
import Entry from ‘./Entry’;
Font.register({
family: ‘Public Sans’,
fonts: [
{ src: ‘https://cdn.companycam.com/fonts/PublicSans-Regular.ttf’ },
{
src: ‘https://cdn.companycam.com/fonts/PublicSans-SemiBold.ttf’,
fontWeight: 700,
},
],
});
// Allow users to include emojis in markup
Font.registerEmojiSource({
format: ‘png’,
url: ‘https://twemoji.maxcdn.com/2/72x72/‘,
});
const styles = StyleSheet.create({
// omitted for brevity
});
const Document = ({ report }) => {
const {
entries,
settings,
photoCount,
company,
title,
subtitle,
createdAt,
featuredEntry
} = report;
const { name, logoLargeUrl } = company;
const featuredPhoto = featuredEntry.assetPreviewLarge;
return (
<PDFDocument>
<Page style={pageStyles} size="LETTER">
<PageFooter title={title} />
<CoverPage
companyName={name}
logo={logoLargeUrl}
title={title}
subtitle={subtitle}
createdAt={createdAt}
featuredPhoto={featuredPhoto}
photoCount={photoCount}
/>
</Page>
<Page style={pageStyles} size="LETTER">
<PageFooter title={title} />
{entries.map((entry) => {
return (
<Entry
key={entry.id}
entry={entry}
settings={settings}
/>
);
})}
</Page>
</PDFDocument>
);
};
export default Document;

The wrapping component is a Document component imported from @react-pdf. All components inside of this component must be a @react-pdf component.

The Page, CoverPage and PageFooter are all self explanatory so I won’t show the code. We render a page at the beginning of the document that includes the company's name and title of the report. We then render an Entry for each item in the report. The interesting part here is that we wrap all entries in a single Page component. React PDF has a pretty advanced layout engine. If the content is going to overflow the page, it will wrap to a new page. You can also pass a break prop to add a page break before the component being rendered.

I’ve included the Entry component for completeness. The only interesting part is that it renders a RichText component.

import React from 'react';
import { StyleSheet, View, Image, Text, Link } from '@react-pdf/renderer';
import RichText from '../RichText';
const styles = StyleSheet.create({
// ...omitted
});
const Entry = ({ entry }) => {
const { item, pageBreak } = entry;
const { assetPreview } = item;
return (
<View style={containerStyles} wrap={false} break={pageBreak}>
<Link
src={assetPreviewLarge}
style={imageContainerStyles}
target="_blank"
>
<Image
style={imageStyles}
source={{
uri: assetPreview,
headers: { Pragma: 'no-cache', 'Cache-Control': 'no-cache' },
}}
/>
</Link>
<View style={contentStyles}>
<RichText note={entry.notes} />
</View>
</View>
);
};
export default Entry;

Redraft Meet React PDF

So far, most of the code I have shown you isn’t anything earth shattering. I included it so you could have a better understanding of the hierarchy and flow. In the RichText component we actually do some more interesting work.

import {
EditorState,
ContentState,
convertToRaw,
convertFromHTML,
} from ‘draft-js’;
import redraft from ‘redraft’;
const RichText = ({ note }) => {
const blocksFromHTML = convertFromHTML(note);
const initialState = ContentState.createFromBlockArray(
blocksFromHTML.contentBlocks,
blocksFromHTML.entityMap,
);
const editorState = EditorState.createWithContent(initialState);
const rawContent = convertToRaw(editorState.getCurrentContent());
return redraft(rawContent, renderers, { blockFallback: ‘unstyled’ });
};
export default RichText;

The component is quite simple; it handles converting the HTML to a format that is understood by DraftJS using convertFromHTML.

If you’re using DraftJS to generate the HTML, you can store the raw JS object from Draft using convertToRaw while editing.

Once we have converted the HTML into blocks, we initialize a ContentState and an EditorState. We do all of that to finally get the rawContent. The raw content is a regular ole’ JS object. I recommend you log it to the console so you can see how parseable the data is. Finally we’re at the cool part, we return the result of calling redraft with the rawContent, renderers, and some options.

Renderers

The renderers object is a object that maps from the original type to the new component. Redraft uses the renderers object and iterates over the blocks, inline elements, and entities in the raw JS object, calling the corresponding function based on the type. DraftJS supports a handful of elements out of the box, so we make a compatible React PDF component for each of the types that we support. Let’s take a look at our renderers:

import { StyleSheet, View, Text, Link } from ‘@react-pdf/renderer’;const renderers = {
inline: {
// The key passed here is just an index based on rendering order inside a block
BOLD: (children, { key }) => {
return (
<Text key={`bold-${key}`} style={{ fontWeight: 700 }}>
{children}
</Text>
);
},
ITALIC: (children, { key }) => {
return (
<Text key={`italic-${key}`} style={{ fontStyle: ‘italic’ }}>
{children}
</Text>
);
},
UNDERLINE: (children, { key }) => {
return (
<Text key={`underline-${key}`} style={{ textDecoration: ‘underline’ }}>
{children}
</Text>
);
},
},
/**
* Blocks receive children and depth
* Note that children are an array of blocks with same styling,
*/
blocks: {
unstyled: (children, { keys }) => {
return children.map((child, index) => {
return (
<View key={keys[index]}>
<Text style={styles.text}>{child}</Text>
</View>
);
});
},
‘header-one’: (children, { keys }) => {
return children.map((child, index) => {
return <HeadingOne key={keys[index]}>{child}</HeadingOne>;
});
},
‘unordered-list-item’: (children, { depth, keys }) => {
return (
<UnorderedList key={keys[keys.length - 1]} depth={depth}>
{children.map((child, index) => (
<UnorderedListItem key={keys[index]}>{child}</UnorderedListItem>
))}
</UnorderedList>
);
},
‘ordered-list-item’: (children, { depth, keys }) => {
return (
<OrderedList key={keys.join(‘|’)} depth={depth}>
{children.map((child, index) => (
<OrderedListItem key={keys[index]} index={index}>
{child}
</OrderedListItem>
))}
</OrderedList>
);
},
},
/**
* Entities receive children and the entity data
*/
entities: {
// key is the entity key value from raw
LINK: (children, data, { key }) => (
<Link key={key} src={data.url}>
{children}
</Link>
),
},
};

As you can see we return a text element for all of the inline styles and apply the appropriate styles to match the type. For each of the blocks we created our own components that have applied styles to match the formatting required. For example, here is HeaderOne:

const styles = StyleSheet.create({
headingOne: {
marginBottom: 4,
color: ‘#3a4b56’,
fontWeight: 700,
fontFamily: ‘Public Sans’,
lineHeight: 1.35,
fontSize: 12,
},
});
const HeadingOne = ({ children }) => {
return (
<View>
<Text style={styles.headingOne}>{children}</Text>
</View>
);
};

It a simple Text component wrapped in a View. By default a View will span the full width of the parent. Think of this is a block element in HTML.

One of the more interesting components is for lists. React PDF doesn’t have a built in component for ordered or unordered lists, so we made our own. Boiling it down, a list is just a block element with nested block elements prefixed with either a number or bullet before the entry.

const styles = StyleSheet.create({
list: {
marginBottom: 8,
marginLeft: 6,
},
listItem: {
marginBottom: 4,
},
listItemText: {
color: ‘#6b7880’,
fontFamily: ‘IBM Plex Serif’,
fontSize: 10,
lineHeight: 1.45,
},
});
const UnorderedList = ({ children, depth }) => {
return <View style={styles.list}>{children}</View>;
};
const UnorderedListItem = ({ children }) => {
return (
<View style={styles.listItem}>
<Text style={styles.listItemText}>
• &nbsp;<Text>{children}</Text>
</Text>
</View>
);
};
const OrderedList = ({ children, depth }) => {
return <View style={styles.list}>{children}</View>;
};
const OrderedListItem = ({ children, index }) => {
return (
<View style={styles.listItem}>
<Text style={styles.listItemText}>
{index + 1}. &nbsp;<Text>{children}</Text>
</Text>
</View>
);
};

And there you have it. You have taken user input, parsed it using DraftJS and converted it into elements that can be styled and rendered using React PDF. Implementing new types is as easy as adding a new entry in renderers and styling it.

One place I would like to try using Redraft is to render React Native components from HTML instead of relying on a web view in our mobile app.

For the full code from the example you can check out this gist.

If you want to work on features like this CompanyCam is hiring.

--

--