We will walk through the steps to build a document analysis web application using Nuxt 3 and Azure Document Intelligence. This application allows users to -
Features will include -
- 🔍 Create/pass a template in HTML and placeholders for values
- 🕶️ Real-time document analysis status
- 💾 Download merged content as PDF
Here’s a quick demo.

While the demo showcases invoice generation, the same program can be extended to any document type - filled application forms, documents, etc.
Technology stack -
- NuxtJS
- Tailwind CSS
- PDFJS
- CKEditor
Why Client-Side PDF Generation?
Generating PDFs on the client-side offers several benefits:
- Performance: Offloading PDF generation to the client’s device reduces server load and leads to faster response times.
- User Experience: Users can preview changes in real-time, adjust their inputs, and generate PDFs without a trip to the server.
- Cost-Effective: Avoid additional server infrastructure by leveraging modern browsers and their capabilities.
- Flexibility: Easily integrate rich text editing and custom templates to create personalized documents.
Who is this post for?
- You are trying to roll-out a PDF generator for invoices or filled applications
- You are using an external service to generate PDFs
- You are not satisfied with server-side PDF generators
Setting Up the Project
Before we start, make sure you have Node.js 18.x or later installed in your computer.
Create a blank Nuxt app.
bunx nuxi@latest init docgen
A key component of our PDF generator is a rich text editor to create templates. We will use CKEditor to achieve just that. We will also use a JSON editor to enable editing JSON data.
bun i ckeditor/ckeditor5-vue ckeditor5 vue3-json-editor
We will use pdfjs to preview and generate PDF, and DomPurify to make sure we are rendering things safer. Also, install types while at it.
bun i dompurify html2canvas jspdf pdfjs-dist pdfmake
bun i -d @types/pdfmake
Add styling and icons.
bunx nuxi@latest module add tailwindcss lucide-vue-next
Develop the app
Rich Text Editor
Rich text editor is one of the main components that enables users to edit templates. You will not need the rich text editor if you don’t plan to edit templates within the app (the template is plain HTML anyway.)
<template>
<div class="richtext-editor">
<ckeditor
v-if="editor && config && modelValue"
:editor="editor"
v-model="modelValue"
:config="config"
/>
<div
v-if="previewContent"
class="preview-overlay prose"
v-html="previewContent"
></div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import {
ClassicEditor,
Autoformat,
AutoImage,
Autosave,
Base64UploadAdapter,
Bold,
Essentials,
// other items - see GitHub link
} from "ckeditor5";
import "ckeditor5/ckeditor5.css";
import { Ckeditor } from "@ckeditor/ckeditor5-vue";
import "ckeditor5/ckeditor5.css";
const isLayoutReady = ref(false);
const editor = ClassicEditor;
const LICENSE_KEY = "GPL"; // or <YOUR_LICENSE_KEY>.
const config = computed(() => {
if (!isLayoutReady.value) {
return null;
}
return {
toolbar: {
items: [
"sourceEditing",
"|",
"heading",
"|",
"fontSize",
"fontFamily",
// other toolbar items. See GitHub link for all buttons
],
shouldNotGroupWhenFull: false,
},
plugins: [
// relevant plugins - see GitHub link
],
fontFamily: {
supportAllValues: true,
},
fontSize: {
options: [10, 12, 14, "default", 18, 20, 22],
supportAllValues: true,
},
heading: {
options: [
{
model: "paragraph",
title: "Paragraph",
class: "ck-heading_paragraph",
},
{
model: "heading1",
view: "h1",
title: "Heading 1",
class: "ck-heading_heading1",
},
{
model: "heading2",
view: "h2",
title: "Heading 2",
class: "ck-heading_heading2",
},
{
model: "heading3",
view: "h3",
title: "Heading 3",
class: "ck-heading_heading3",
},
{
model: "heading4",
view: "h4",
title: "Heading 4",
class: "ck-heading_heading4",
},
{
model: "heading5",
view: "h5",
title: "Heading 5",
class: "ck-heading_heading5",
},
{
model: "heading6",
view: "h6",
title: "Heading 6",
class: "ck-heading_heading6",
},
],
},
image: {
toolbar: ["imageTextAlternative"],
},
initialData: "..",
licenseKey: LICENSE_KEY,
link: {
addTargetToExternalLinks: true,
defaultProtocol: "https://",
decorators: {
toggleDownloadable: {
mode: "manual",
label: "Downloadable",
attributes: {
download: "file",
},
},
},
},
placeholder: "Type or paste your content here!",
table: {
contentToolbar: [
"tableColumn",
"tableRow",
"mergeTableCells",
"tableProperties",
"tableCellProperties",
],
},
};
});
const modelValue = defineModel();
const props = defineProps<{
previewContent?: string;
}>();
onMounted(() => {
isLayoutReady.value = true;
});
</script>
<style>
/* include styles */
</style>
JSON Editor
Create the JSON editor that is used to edit JSON data.
<!-- components/DataEditor.vue -->
<template>
<ClientOnly>
<div
class="h-[300px] border border-white/20 rounded-lg overflow-hidden bg-white/5"
>
<textarea
v-model="jsonString"
class="w-full h-full p-4 font-mono text-sm bg-transparent text-black dark:text-white/90 focus:outline-none"
@input="handleInput"
spellcheck="false"
></textarea>
</div>
<div v-if="error" class="mt-2 text-red-400 text-sm">
{{ error }}
</div>
</ClientOnly>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { useDocumentStore } from "~/stores/document";
const props = defineProps<{ modelValue: Record<string, any> }>();
const emit = defineEmits<{
"update:modelValue": [value: Record<string, any>];
}>();
const documentStore = useDocumentStore();
const error = ref("");
const jsonString = ref(JSON.stringify(props.modelValue, null, 2));
watch(
() => props.modelValue,
(newVal) => {
const newString = JSON.stringify(newVal, null, 2);
if (jsonString.value !== newString) {
jsonString.value = newString;
}
}
);
const handleInput = (event: Event) => {
const target = event.target as HTMLTextAreaElement;
try {
const parsed = JSON.parse(target.value);
error.value = "";
documentStore.isDirty = true;
emit("update:modelValue", parsed);
} catch (e) {
error.value = "Invalid JSON format";
}
};
</script>
<style>
/* include styles */
</style>
Create PDF Viewer
PDF viewer shows the PDF after merging data in template.
<!-- components/PdfViewer.vue -->
<template>
<div class="pdf-viewer">
<div v-if="loading" class="loading-indicator">
<div class="spinner"></div>
<div>Generating PDF preview...</div>
</div>
<div v-else-if="error" class="error-message">
{{ error }}
</div>
<div v-else ref="canvasContainer" class="canvas-container"></div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch, onBeforeUnmount } from "vue";
const props = defineProps<{
content: string;
}>();
const canvasContainer = ref<HTMLElement | null>(null);
const loading = ref(true);
const error = ref<string | null>(null);
let pdfDoc: any = null;
let currentPage = 1;
let pageRendering = false;
let pageNumPending: number | null = null;
let pdfjsLib: any = null;
// Clean up any resources when component is unmounted
onBeforeUnmount(() => {
if (pdfDoc) {
pdfDoc = null;
}
});
// Initialize PDF.js
onMounted(async () => {
if (process.client) {
try {
// Import PDF.js dynamically
const pdfjsModule = await import("pdfjs-dist");
pdfjsLib = pdfjsModule;
// Set worker source - using CDN for the worker
pdfjsLib.GlobalWorkerOptions.workerSrc =
"https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
// Generate and render PDF
await generatePdf();
} catch (err) {
console.error("Error initializing PDF.js:", err);
error.value = "Failed to initialize PDF viewer. Please try again.";
loading.value = false;
}
}
});
// Watch for content changes
watch(
() => props.content,
async () => {
if (pdfjsLib && process.client) {
loading.value = true;
error.value = null;
await generatePdf();
}
}
);
// Generate PDF from content
async function generatePdf() {
if (!pdfjsLib || !process.client) return;
try {
// Import pdfMake dynamically
const pdfMakeModule = await import("pdfmake/build/pdfmake");
const pdfFontsModule = await import("pdfmake/build/vfs_fonts");
// Get the default export or the module itself
const pdfMake = pdfMakeModule.default || pdfMakeModule;
// Manually set up the vfs with a basic font
if (!pdfMake.vfs) {
// @ts-ignore - We know this property might exist
pdfMake.vfs = pdfFontsModule.pdfMake?.vfs || {};
}
// Create a temporary element to parse the HTML
const tempDiv = document.createElement("div");
tempDiv.innerHTML = props.content || "";
// Extract text content and structure
const pdfContent = extractPdfContent(tempDiv);
// Define the document with basic fonts
const docDefinition = {
content: pdfContent,
defaultStyle: {
font: "Roboto",
fontSize: 12,
lineHeight: 1.5,
},
pageSize: "A4",
pageMargins: [40, 60, 40, 60] as [number, number, number, number],
};
// Generate PDF as Blob
const pdfDocGenerator = pdfMake.createPdf(docDefinition);
pdfDocGenerator.getBlob(async (blob: Blob) => {
try {
// Convert blob to array buffer
const arrayBuffer = await blob.arrayBuffer();
// Load PDF document
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
pdfDoc = await loadingTask.promise;
// Initial/first page rendering
await renderPage(1);
loading.value = false;
} catch (err) {
console.error("Error rendering PDF:", err);
error.value = "Failed to render PDF preview.";
loading.value = false;
}
});
} catch (err) {
console.error("Error generating PDF:", err);
error.value = "Failed to generate PDF preview.";
loading.value = false;
}
}
// Render the specified page
async function renderPage(num: number) {
if (!pdfDoc) return;
pageRendering = true;
try {
// Get page
const page = await pdfDoc.getPage(num);
// Clear previous content
if (canvasContainer.value) {
canvasContainer.value.innerHTML = "";
}
// Calculate scale to fit the container width
const containerWidth = canvasContainer.value?.clientWidth || 800;
const viewport = page.getViewport({ scale: 1 });
const scale = containerWidth / viewport.width;
const scaledViewport = page.getViewport({ scale });
// Create canvas for each page
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.height = scaledViewport.height;
canvas.width = scaledViewport.width;
canvas.className = "pdf-page";
// Add canvas to container
canvasContainer.value?.appendChild(canvas);
// Render PDF page into canvas context
const renderContext = {
canvasContext: ctx,
viewport: scaledViewport,
};
await page.render(renderContext).promise;
pageRendering = false;
// If there's a pending page, render it
if (pageNumPending !== null) {
renderPage(pageNumPending);
pageNumPending = null;
}
// If there are more pages, render them too
if (num < pdfDoc.numPages) {
renderPage(num + 1);
}
} catch (err) {
console.error("Error rendering page:", err);
pageRendering = false;
error.value = "Failed to render PDF page.";
}
}
// Extract content from HTML for PDF generation
function extractPdfContent(element: HTMLElement): any[] {
const result: any[] = [];
// Process child nodes
Array.from(element.childNodes).forEach((node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent?.trim() || "";
if (text) {
result.push({ text });
}
} else if (node.nodeType === Node.ELEMENT_NODE) {
const elementNode = node as HTMLElement;
const nodeName = elementNode.nodeName.toLowerCase();
// Handle different element types
if (nodeName === "p") {
result.push({
text: elementNode.textContent?.trim() || "",
margin: [0, 5, 0, 10],
});
} else if (nodeName === "h1") {
result.push({
text: elementNode.textContent?.trim() || "",
fontSize: 24,
bold: true,
margin: [0, 10, 0, 10],
});
} else if (nodeName === "h2") {
result.push({
text: elementNode.textContent?.trim() || "",
fontSize: 20,
bold: true,
margin: [0, 10, 0, 5],
});
} else if (nodeName === "h3") {
result.push({
text: elementNode.textContent?.trim() || "",
fontSize: 16,
bold: true,
margin: [0, 10, 0, 5],
});
} else if (nodeName === "ul" || nodeName === "ol") {
const items = Array.from(elementNode.children).map((li) => {
return { text: li.textContent?.trim() || "", margin: [0, 2, 0, 2] };
});
result.push({
ul: nodeName === "ul" ? items : undefined,
ol: nodeName === "ol" ? items : undefined,
margin: [0, 5, 0, 10],
});
} else if (nodeName === "table") {
// Extract table data
const tableData: string[][] = [];
Array.from(elementNode.querySelectorAll("tr")).forEach((row) => {
const rowData: string[] = [];
Array.from(row.querySelectorAll("td, th")).forEach((cell) => {
rowData.push(cell.textContent?.trim() || "");
});
if (rowData.length > 0) {
tableData.push(rowData);
}
});
if (tableData.length > 0) {
result.push({
table: {
body: tableData,
widths: Array(tableData[0].length).fill("*"),
},
margin: [0, 5, 0, 10],
});
}
} else {
// Recursively process other elements
const childContent = extractPdfContent(elementNode);
if (childContent.length > 0) {
result.push(...childContent);
}
}
}
});
return result;
}
</script>
Include this in a DocumentPreview.vue component that can toggle between PDF viewer and a rich text viewer - if needed, of course.
Layout and Page
Update layout -
<!-- layouts/default.vue -->
<template>
<div class="min-h-screen bg-surface-ground">
<nav class="bg-surface-card border-b border-surface-border md:px-12 px-6">
<div class=" h-16 flex items-center justify-between">
<h1 class="text-xl font-bold text-black">docgen</h1>
<div class="flex items-center gap-4">
<button
variant="secondary"
:icon="isDark ? 'sun' : 'moon'"
@click="toggleTheme"
:title="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
/>
</div>
</div>
</nav>
<main class=" py-6">
<slot />
</main>
</div>
</template>
<script setup lang="ts"></script>
<style></style>
… and the page.
<template>
<div class="mx-auto px-12">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="flex flex-col gap-6">
<div class="card">
<div class="toolbar">
<h2 class="section-title">Template</h2>
</div>
<div class="h-[600px] overflow-hidden">
<ClientOnly>
<RichTextEditor v-model="template" class="h-full" />
</ClientOnly>
</div>
</div>
<div class="card">
<!-- Removed flex-1 from card -->
<div class="toolbar">
<h2 class="section-title">Data (JSON)</h2>
</div>
<div class="editor-container h-[400px] overflow-hidden">
<DataEditor v-model="jsonData" class="h-full" />
</div>
</div>
</div>
<div class="card">
<!-- Removed flex-1 from card -->
<div class="toolbar">
<h2 class="section-title">Generated Document</h2>
<div class="toolbar-actions">
<!-- <Button variant="secondary" icon="refresh">Auto-refresh</Button> -->
</div>
</div>
<div class="h-[1024px] overflow-hidden">
<DocumentPreview :data="jsonData" class="h-full" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useDocumentStore } from "~/stores/document";
import { storeToRefs } from "pinia";
const documentStore = useDocumentStore();
const { template, jsonData } = storeToRefs(documentStore);
</script>
<style scoped>
/* styles */
</style>
Run the app!
bun dev
This should populate some default template and data, and show you a nice preview of the merged content.
Conclusion
Generating PDF on client is not hard and keeps your server load low.
While generating PDFs client-side can certainly help in most use cases (especially for enterprise customers), it may not be a pleasant experience for mobile users. Also, features like emailing PDF, supporting non-PDF formats, etc. are hard to achieve.
See complete code on GitHub.