Skip to main content

Bulk PDF Generation

Process high-volume PDF generation with Map/Reduce scripts, outputting concatenated PDFs or ZIP archives.


Overview

BULK PDF ARCHITECTURE
═══════════════════════════════════════════════════════════════════════════════

┌─────────────────────────────────────────────────────────────────────────────┐
│ WHY MAP/REDUCE FOR BULK PDF? │
│ │
│ • Handles 1,000s of records without timeout │
│ • Automatic restart on failure │
│ • Parallel processing for speed │
│ • Governance-aware execution │
│ • Perfect for scheduled batch jobs │
└─────────────────────────────────────────────────────────────────────────────┘

PROCESS FLOW:
┌─────────────────────────────────────────────────────────────────────────────┐
│ │
│ GET INPUT MAP REDUCE SUMMARIZE │
│ ───────── ─── ────── ───────── │
│ ┌─────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ Search │ → │ Generate │ → │ Merge PDFs │ → │ Save File │ │
│ │ Records │ │ Each PDF │ │ or Create │ │ Send Email│ │
│ │ │ │ │ │ ZIP │ │ Notify │ │
│ └─────────┘ └───────────┘ └───────────┘ └───────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

Output Options

BULK PDF OUTPUT TYPES
═══════════════════════════════════════════════════════════════════════════════

OPTION 1: CONCATENATED PDF (Single merged file)
┌─────────────────────────────────────────────────────────────────────────────┐
│ • All PDFs merged into one document │
│ • Good for: Printing batch, sequential viewing │
│ • Example: 500 invoices → 1 PDF file (500+ pages) │
│ │
│ Pros: │
│ • Single file download │
│ • Easy to print entire batch │
│ • Smaller total size (shared resources) │
│ │
│ Cons: │
│ • Harder to find specific document │
│ • Can't email individual documents │
└─────────────────────────────────────────────────────────────────────────────┘

OPTION 2: ZIP ARCHIVE (Multiple files in ZIP)
┌─────────────────────────────────────────────────────────────────────────────┐
│ • Each PDF as separate file in ZIP │
│ • Good for: Distribution, archiving, individual access │
│ • Example: 500 invoices → 1 ZIP file (500 PDF files) │
│ │
│ Pros: │
│ • Individual files accessible │
│ • Can email specific documents │
│ • Better for archival │
│ │
│ Cons: │
│ • Larger total file size │
│ • Must extract before printing │
└─────────────────────────────────────────────────────────────────────────────┘

Concatenated PDF (Map/Reduce)

Complete Script

/**
* @NApiVersion 2.1
* @NScriptType MapReduceScript
* @description Generate merged PDF from multiple invoices
*/
define(['N/search', 'N/render', 'N/file', 'N/email', 'N/runtime', 'N/record'],
function(search, render, file, email, runtime, record) {

/**
* Get input - return search for invoices to process
*/
function getInputData() {
var scriptParams = runtime.getCurrentScript();
var startDate = scriptParams.getParameter({ name: 'custscript_pdf_start_date' });
var endDate = scriptParams.getParameter({ name: 'custscript_pdf_end_date' });

return search.create({
type: search.Type.INVOICE,
filters: [
['trandate', 'within', startDate, endDate],
'AND',
['mainline', 'is', 'T'],
'AND',
['status', 'anyof', 'CustInvc:A'] // Open invoices
],
columns: [
'internalid',
'tranid',
'entity',
'total'
]
});
}

/**
* Map - generate individual PDF for each invoice
*/
function map(context) {
var searchResult = JSON.parse(context.value);
var invoiceId = searchResult.id;
var tranid = searchResult.values.tranid;

try {
// Generate PDF for this invoice
var pdfFile = render.transaction({
entityId: parseInt(invoiceId),
printMode: render.PrintMode.PDF
});

// Get PDF content as base64
var pdfContent = pdfFile.getContents();

// Write to reduce with invoice ID as key
context.write({
key: 'batch', // Single key to merge all
value: JSON.stringify({
invoiceId: invoiceId,
tranid: tranid,
pdfBase64: pdfContent,
pageCount: 1 // Estimate, or calculate from content
})
});

} catch (e) {
log.error({
title: 'Error generating PDF for Invoice ' + tranid,
details: e.message
});
}
}

/**
* Reduce - merge all PDFs into single document
*/
function reduce(context) {
var pdfContents = [];

// Collect all PDF contents
context.values.forEach(function(value) {
var data = JSON.parse(value);
pdfContents.push({
invoiceId: data.invoiceId,
tranid: data.tranid,
content: data.pdfBase64
});
});

// Sort by transaction ID
pdfContents.sort(function(a, b) {
return a.tranid.localeCompare(b.tranid);
});

// Create merged PDF using XML
var mergedXml = buildMergedPdfXml(pdfContents);

var mergedPdf = render.xmlToPdf({
xmlString: mergedXml
});

// Save to File Cabinet
mergedPdf.folder = getFolderId();
mergedPdf.name = 'Invoice_Batch_' + getDateStamp() + '.pdf';

var fileId = mergedPdf.save();

// Write result for summarize
context.write({
key: 'result',
value: JSON.stringify({
fileId: fileId,
fileName: mergedPdf.name,
invoiceCount: pdfContents.length
})
});
}

/**
* Build merged PDF XML with embedded PDFs
*/
function buildMergedPdfXml(pdfContents) {
var pdfReferences = pdfContents.map(function(pdf, index) {
// For true PDF merging, we need to use pdfset
return '<pdf src="data:application/pdf;base64,' + pdf.content + '" />';
}).join('\n');

return `<?xml version="1.0"?>
<!DOCTYPE pdf PUBLIC "-//big.faceless.org//report" "report-1.1.dtd">
<pdfset>
${pdfReferences}
</pdfset>`;
}

/**
* Summarize - send notification
*/
function summarize(summary) {
var result = null;

summary.output.iterator().each(function(key, value) {
result = JSON.parse(value);
return true;
});

if (result) {
// Send email notification
var recipient = runtime.getCurrentScript()
.getParameter({ name: 'custscript_pdf_notify_email' });

if (recipient) {
email.send({
author: -5,
recipients: recipient,
subject: 'Bulk Invoice PDF Generated',
body: 'Generated merged PDF with ' + result.invoiceCount +
' invoices.\nFile ID: ' + result.fileId
});
}

log.audit({
title: 'Bulk PDF Complete',
details: 'Generated ' + result.invoiceCount + ' invoices. File ID: ' + result.fileId
});
}

// Log any errors
if (summary.inputSummary.error) {
log.error('Input Error', summary.inputSummary.error);
}

summary.mapSummary.errors.iterator().each(function(key, error) {
log.error('Map Error for key ' + key, error);
return true;
});

summary.reduceSummary.errors.iterator().each(function(key, error) {
log.error('Reduce Error for key ' + key, error);
return true;
});
}

function getFolderId() {
// Return your target folder ID
return 123;
}

function getDateStamp() {
var now = new Date();
return now.getFullYear() +
String(now.getMonth() + 1).padStart(2, '0') +
String(now.getDate()).padStart(2, '0');
}

return {
getInputData: getInputData,
map: map,
reduce: reduce,
summarize: summarize
};
});

ZIP Archive Output (Map/Reduce)

ZIP Generation Script

/**
* @NApiVersion 2.1
* @NScriptType MapReduceScript
* @description Generate ZIP archive with individual PDF files
*/
define(['N/search', 'N/render', 'N/file', 'N/email', 'N/runtime', 'N/compress'],
function(search, render, file, email, runtime, compress) {

function getInputData() {
var scriptParams = runtime.getCurrentScript();
var startDate = scriptParams.getParameter({ name: 'custscript_zip_start_date' });
var endDate = scriptParams.getParameter({ name: 'custscript_zip_end_date' });

return search.create({
type: search.Type.INVOICE,
filters: [
['trandate', 'within', startDate, endDate],
'AND',
['mainline', 'is', 'T']
],
columns: ['internalid', 'tranid', 'entity']
});
}

/**
* Map - save individual PDFs to temporary folder
*/
function map(context) {
var searchResult = JSON.parse(context.value);
var invoiceId = searchResult.id;
var tranid = searchResult.values.tranid;
var customer = searchResult.values.entity.text || 'Unknown';

try {
// Generate PDF
var pdfFile = render.transaction({
entityId: parseInt(invoiceId),
printMode: render.PrintMode.PDF
});

// Save to temp folder
var tempFolderId = getTempFolderId();
pdfFile.folder = tempFolderId;
pdfFile.name = sanitizeFilename(tranid + '_' + customer + '.pdf');

var fileId = pdfFile.save();

// Pass file ID to reduce
context.write({
key: 'batch',
value: JSON.stringify({
fileId: fileId,
fileName: pdfFile.name
})
});

} catch (e) {
log.error('Error processing invoice ' + tranid, e.message);
}
}

/**
* Reduce - create ZIP from all saved PDFs
*/
function reduce(context) {
var pdfFiles = [];

// Collect file references
context.values.forEach(function(value) {
var data = JSON.parse(value);
pdfFiles.push({
fileId: data.fileId,
fileName: data.fileName
});
});

// Create ZIP archive
var archiver = compress.createArchiver();

pdfFiles.forEach(function(pdfInfo) {
var pdfFile = file.load({ id: pdfInfo.fileId });
archiver.add({
file: pdfFile,
directory: '' // Root of ZIP
});
});

// Generate ZIP file
var zipFile = archiver.archive({
name: 'Invoices_' + getDateStamp() + '.zip'
});

// Save ZIP to File Cabinet
zipFile.folder = getOutputFolderId();
var zipFileId = zipFile.save();

// Clean up temp PDFs
pdfFiles.forEach(function(pdfInfo) {
file.delete({ id: pdfInfo.fileId });
});

// Write result
context.write({
key: 'result',
value: JSON.stringify({
zipFileId: zipFileId,
zipFileName: zipFile.name,
fileCount: pdfFiles.length
})
});
}

/**
* Summarize - notification and cleanup
*/
function summarize(summary) {
var result = null;

summary.output.iterator().each(function(key, value) {
result = JSON.parse(value);
return true;
});

if (result) {
// Get download URL
var zipFile = file.load({ id: result.zipFileId });
var downloadUrl = zipFile.url;

// Send notification
var recipient = runtime.getCurrentScript()
.getParameter({ name: 'custscript_zip_notify_email' });

if (recipient) {
email.send({
author: -5,
recipients: recipient,
subject: 'Invoice ZIP Archive Ready',
body: 'Generated ZIP with ' + result.fileCount + ' invoices.\n\n' +
'Download: ' + downloadUrl
});
}

log.audit('ZIP Complete', result.fileCount + ' files in ZIP. ID: ' + result.zipFileId);
}

// Log errors
logErrors(summary);
}

function logErrors(summary) {
if (summary.inputSummary.error) {
log.error('Input Error', summary.inputSummary.error);
}
summary.mapSummary.errors.iterator().each(function(key, error) {
log.error('Map Error', key + ': ' + error);
return true;
});
summary.reduceSummary.errors.iterator().each(function(key, error) {
log.error('Reduce Error', key + ': ' + error);
return true;
});
}

function sanitizeFilename(name) {
return name.replace(/[^a-zA-Z0-9_\-\.]/g, '_');
}

function getTempFolderId() {
return 456; // Temp folder ID
}

function getOutputFolderId() {
return 789; // Output folder ID
}

function getDateStamp() {
var now = new Date();
return now.getFullYear() +
String(now.getMonth() + 1).padStart(2, '0') +
String(now.getDate()).padStart(2, '0');
}

return {
getInputData: getInputData,
map: map,
reduce: reduce,
summarize: summarize
};
});

Script Deployment

Script Record

<!-- customscript_bulk_pdf.xml -->
<mapreducescript scriptid="customscript_bulk_pdf">
<name>Bulk PDF Generator</name>
<scriptfile>[/SuiteScripts/bulk_pdf_generator.js]</scriptfile>
<notifyadmins>T</notifyadmins>
<description>Generates bulk PDFs from invoice search</description>

<scriptcustomfields>
<scriptcustomfield scriptid="custscript_pdf_start_date">
<label>Start Date</label>
<type>DATE</type>
<ismandatory>T</ismandatory>
</scriptcustomfield>
<scriptcustomfield scriptid="custscript_pdf_end_date">
<label>End Date</label>
<type>DATE</type>
<ismandatory>T</ismandatory>
</scriptcustomfield>
<scriptcustomfield scriptid="custscript_pdf_notify_email">
<label>Notification Email</label>
<type>EMAIL</type>
</scriptcustomfield>
</scriptcustomfields>
</mapreducescript>

Deployment Configuration

<!-- customdeploy_bulk_pdf.xml -->
<mapreducescriptdeployment scriptid="customdeploy_bulk_pdf">
<status>SCHEDULED</status>
<title>Bulk PDF - Monthly Invoices</title>
<isdeployed>T</isdeployed>
<loglevel>AUDIT</loglevel>

<!-- Schedule: First day of each month at 2 AM -->
<recurrence>
<single>
<startdate>2024-01-01</startdate>
<starttime>02:00:00</starttime>
</single>
<monthly>
<dayofmonth>1</dayofmonth>
</monthly>
</recurrence>
</mapreducescriptdeployment>

Triggering from Suitelet

User-Triggered Bulk PDF

/**
* @NApiVersion 2.1
* @NScriptType Suitelet
* @description UI to trigger bulk PDF generation
*/
define(['N/ui/serverWidget', 'N/task', 'N/redirect', 'N/url'],
function(serverWidget, task, redirect, url) {

function onRequest(context) {
if (context.request.method === 'GET') {
var form = serverWidget.createForm({
title: 'Generate Bulk Invoice PDFs'
});

form.addField({
id: 'custpage_start',
type: serverWidget.FieldType.DATE,
label: 'Start Date'
}).isMandatory = true;

form.addField({
id: 'custpage_end',
type: serverWidget.FieldType.DATE,
label: 'End Date'
}).isMandatory = true;

form.addField({
id: 'custpage_output',
type: serverWidget.FieldType.SELECT,
label: 'Output Type'
}).addSelectOption({ value: 'merged', text: 'Merged PDF' })
.addSelectOption({ value: 'zip', text: 'ZIP Archive' });

form.addField({
id: 'custpage_email',
type: serverWidget.FieldType.EMAIL,
label: 'Notification Email'
});

form.addSubmitButton({ label: 'Generate PDFs' });

context.response.writePage(form);

} else {
var startDate = context.request.parameters.custpage_start;
var endDate = context.request.parameters.custpage_end;
var outputType = context.request.parameters.custpage_output;
var notifyEmail = context.request.parameters.custpage_email;

// Determine which script to run
var scriptId = outputType === 'zip'
? 'customscript_bulk_pdf_zip'
: 'customscript_bulk_pdf_merged';

// Create Map/Reduce task
var mrTask = task.create({
taskType: task.TaskType.MAP_REDUCE,
scriptId: scriptId,
deploymentId: 'customdeploy_bulk_pdf_ondemand',
params: {
'custscript_pdf_start_date': startDate,
'custscript_pdf_end_date': endDate,
'custscript_pdf_notify_email': notifyEmail
}
});

var taskId = mrTask.submit();

// Show confirmation
var form = serverWidget.createForm({
title: 'Bulk PDF Generation Started'
});

form.addField({
id: 'custpage_message',
type: serverWidget.FieldType.INLINEHTML,
label: ' '
}).defaultValue = '<h2>Task Submitted</h2>' +
'<p>Task ID: ' + taskId + '</p>' +
'<p>You will receive an email when complete.</p>' +
'<p><a href="/app/common/scripting/mapreducescriptstatus.nl">Check Status</a></p>';

context.response.writePage(form);
}
}

return { onRequest: onRequest };
});

Performance Optimization

BULK PDF PERFORMANCE TIPS
═══════════════════════════════════════════════════════════════════════════════

CHUNKING:
┌─────────────────────────────────────────────────────────────────────────────┐
│ • Process in batches to avoid memory issues │
│ • Use multiple reduce keys (e.g., by date or customer) │
│ • Merge smaller batches, then merge batches together │
└─────────────────────────────────────────────────────────────────────────────┘

MEMORY MANAGEMENT:
┌─────────────────────────────────────────────────────────────────────────────┐
│ • Clear variables after use │
│ • Use file system for large interim storage │
│ • Stream PDFs to files instead of holding in memory │
└─────────────────────────────────────────────────────────────────────────────┘

GOVERNANCE:
┌─────────────────────────────────────────────────────────────────────────────┐
│ • Map/Reduce handles governance automatically │
│ • Monitor usage in summarize stage │
│ • Split very large jobs across multiple deployments │
└─────────────────────────────────────────────────────────────────────────────┘

SCHEDULING:
┌─────────────────────────────────────────────────────────────────────────────┐
│ • Run during off-peak hours │
│ • Avoid month-end processing times │
│ • Consider time zones for global operations │
└─────────────────────────────────────────────────────────────────────────────┘

Error Recovery

/**
* Handle failures and allow restart
*/
function map(context) {
var searchResult = JSON.parse(context.value);
var invoiceId = searchResult.id;

try {
// Check if already processed (for restarts)
if (isAlreadyProcessed(invoiceId)) {
log.debug('Skipping', 'Invoice ' + invoiceId + ' already processed');
return;
}

// Generate PDF
var pdfFile = render.transaction({
entityId: parseInt(invoiceId),
printMode: render.PrintMode.PDF
});

// Mark as processed
markAsProcessed(invoiceId);

context.write({
key: 'batch',
value: JSON.stringify({ /* ... */ })
});

} catch (e) {
// Log but don't throw - allows other records to process
log.error('Error for invoice ' + invoiceId, e.message);

// Track for retry
context.write({
key: 'errors',
value: JSON.stringify({
invoiceId: invoiceId,
error: e.message
})
});
}
}

Best Practices

BULK PDF BEST PRACTICES
═══════════════════════════════════════════════════════════════════════════════

DESIGN:
┌─────────────────────────────────────────────────────────────────────────────┐
│ ✓ Plan output structure before coding │
│ ✓ Consider file naming conventions │
│ ✓ Design for restartability │
│ ✓ Include progress tracking │
└─────────────────────────────────────────────────────────────────────────────┘

TESTING:
┌─────────────────────────────────────────────────────────────────────────────┐
│ ✓ Test with small batch first (10-20 records) │
│ ✓ Verify PDF quality and formatting │
│ ✓ Test error scenarios │
│ ✓ Validate merged/ZIP output │
└─────────────────────────────────────────────────────────────────────────────┘

OPERATIONS:
┌─────────────────────────────────────────────────────────────────────────────┐
│ ✓ Set up email notifications │
│ ✓ Monitor execution in Script Status page │
│ ✓ Clean up temp files after processing │
│ ✓ Archive old output files periodically │
└─────────────────────────────────────────────────────────────────────────────┘

Next Steps

GoalGo To
Transaction templatesAdvanced PDF Templates →
On-demand PDFSuitelet PDF Generation →
Return to PDF overviewPDF Customization →