Testing HTML-to-PDF exports: fonts, page breaks, and missing text
A successful generation request gives you a file. It does not tell you whether a price symbol changed, a required label disappeared, or a table broke where you expected it to. This tutorial uses ComPDF to generate catal
A successful generation request gives you a file. It does not tell you whether a price symbol changed, a required label disappeared, or a table broke where you expected it to.
This tutorial uses ComPDF to generate catalog PDFs with images, prices, and component tables, then uses PDF.js to check the output. Three smaller teaching samples show how to detect missing text, font problems, and unexpected page counts.
Generate a catalog with text, images, and tables
This example uses ComPDF PDF generation to turn a catalog layout into PDFs containing text, images, and tables. The same layout is filled with two teaching datasets: four component rows and 32 component rows.
Node.js fills the values and rows into HTML. The request then sends generationType=html, html, pageWidth=595, and pageHeight=842 to ComPDF. The API performs the HTML-to-PDF generation; the local data interpolation is separate application code.
Figure: the actual downloaded PDFs, rendered for inspection. The desk-stand artwork and catalog data are teaching examples. The long output is shown with both pages.
| Input | Actual output | Checked in this run |
|---|---|---|
| MODEL-Q7, four rows, β¬128.50 | One page | Model, price, all four item IDs, headings, end marker, and visible images |
| MODEL-T9, 32 rows, β¬206.40 | Two pages | Model, price, all 32 item IDs, headings, end marker, and visible images |
Every output page measured 595 Γ 842 pt, matching the request. The 32-row table continued onto a second page, with its final row and end marker intact. Visual review of all three pages confirmed that the images were present.
These requests used HTML filled in Node.js. The API's separate server-side template mode is outside this example. For your own exports, check CSS rendering and repeated table headings against the required layout. The smaller samples below help isolate individual failures.
Define the required text and page ranges
For the short sheet, keep MODEL-Q7, 24 V, and 128.50 USD. For multilingual labels, retain the Chinese text, FranΓ§ais, 23 Β°C, and β¬128.50. For the parts list, check the beginning, middle, end, column headings, and final marker.
Page limits belong to these samples. A 2β6 page range catches an implausible result for this parts list; it is not a rule for every export.
Tested locally: Node.js 24.18.0 and pdfjs-dist 5.6.205. Create a directory with samples/ and results/, then install the reader:
npm init -y
npm install --save-exact [email protected]
mkdir -p samples results
Use .mjs filenames for the scripts below. Follow ComPDF API authentication to get a public API key, then store it in a server-side environment variable:
export COMPDF_API_KEY='YOUR_PUBLIC_API_KEY'
Build three small HTML samples
Each file starts with this document structure. Save the first as samples/simple.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Product sheet teaching sample</title>
<style>
body { font-family: Arial, sans-serif; color: #232b43; font-size: 12pt; line-height: 1.5; }
h1 { font-size: 25pt; }
table { width: 100%; border-collapse: collapse; }
td { border: 1px solid #cbd4e1; padding: 8px; }
tr { break-inside: avoid; }
</style>
</head>
<body>
<h1>Model Q7 product sheet</h1>
<p>MODEL-Q7</p>
<p>Supply voltage: 24 V</p>
<p>Unit price: 128.50 USD</p>
<p>Teaching sample. No product performance claims.</p>
</body>
</html>
For samples/multilingual.html, use this body and set the body font family to "DejaVu Sans", "Noto Sans CJK SC", sans-serif:
<h1>Language labels</h1>
<p>MODEL-M2</p>
<p>δΈζοΌδΊ§εθ―΄ζ Β· ζε¦η€ΊδΎ</p>
<p>FranΓ§ais : tempΓ©rature 23 Β°C</p>
<p>Price: β¬128.50</p>
This font selection produced the required text and visible glyphs in the sample. Check font availability and the rendered output in your own environment; a CSS font name alone does not guarantee the expected result.
For samples/long-table.html, keep the document structure and replace the body with a title, a table, and an end marker. This small setup script creates the rows without hand-copying 60 entries:
// make-table.mjs
import fs from 'node:fs/promises';
const source = await fs.readFile('samples/simple.html', 'utf8');
const rows = Array.from({ length: 60 }, (_, i) => {
const n = i + 1;
return `<tr><td>PART-${String(n).padStart(3, '0')}</td><td>Teaching component ${n}</td><td>${n % 5 + 1}</td></tr>`;
}).join('');
const body = `<h1>Model T9 parts list</h1><p>MODEL-T9 β teaching sample</p>
<table><tbody><tr><td>Part</td><td>Description</td><td>Quantity</td></tr>${rows}</tbody></table>
<p>END-OF-PARTS</p>`;
await fs.writeFile('samples/long-table.html', source.replace(/<body>[\s\S]*<\/body>/u, `<body>${body}</body>`));
Run node make-table.mjs to create the 60-row sample. The illustrated catalog exports also embed the ComPDF logo as a data URI, which avoids an external image download. The smaller samples here focus on text and pagination.
Save these requirements as cases.json:
{
"simple": {"expect":["MODEL-Q7","24 V","128.50 USD"],"minPages":1,"maxPages":1},
"multilingual": {"expect":["MODEL-M2","δΊ§εθ―΄ζ","ζε¦η€ΊδΎ","FranΓ§ais","23 Β°C","β¬128.50"],"minPages":1,"maxPages":1},
"long-table": {"expect":["MODEL-T9","PART-001","PART-030","PART-060","END-OF-PARTS","Part","Description","Quantity"],"minPages":2,"maxPages":6}
}
Generate and download the files
Send the HTML to the PDF Generation API. The script below checks the HTTP response, the business code, the returned status, and the downloaded file header. Save it as generate.mjs:
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root=path.dirname(fileURLToPath(import.meta.url));
const names=process.argv.slice(2);
const cases=JSON.parse(await fs.readFile(path.join(root,'cases.json'),'utf8'));
const key=process.env.COMPDF_API_KEY;
if(!key)throw new Error('Set COMPDF_API_KEY to your public API key.');
await fs.mkdir(path.join(root,'results'),{recursive:true});
for(const name of names.length?names:Object.keys(cases).filter(k=>!cases[k].negative)) {
if(!(name in cases))throw new Error(`Unknown case: ${name}`);
const form=new FormData();
form.append('generationType','html');
form.append('html',await fs.readFile(path.join(root,'samples',`${name}.html`),'utf8'));
form.append('outputFileName',`${name}.pdf`);
const response=await fetch('https://api-server.compdf.com/server/v2/process/pdf/generate',{
method:'POST',headers:{'x-api-key':key},body:form,signal:AbortSignal.timeout(120000)
});
if(!response.ok)throw new Error(`${name}: generation HTTP ${response.status}. Not automatically resubmitting.`);
const body=await response.json();
if(String(body.code)!=='200'||body.data?.status!=='success'||!body.data?.downloadUrl)
throw new Error(`${name}: generation not successful (code ${body.code}, status ${body.data?.status}).`);
const download=await fetch(body.data.downloadUrl,{signal:AbortSignal.timeout(30000)});
if(!download.ok)throw new Error(`${name}: download HTTP ${download.status}.`);
const bytes=new Uint8Array(await download.arrayBuffer());
if(Buffer.from(bytes.slice(0,5)).toString()!=='%PDF-')throw new Error(`${name}: response is not a PDF.`);
await fs.writeFile(path.join(root,'results',`${name}.pdf`),bytes);
console.log(`${name}: saved PDF (${bytes.length} bytes)`);
}
Run node generate.mjs. The API key is sent only to the generation endpoint, not forwarded to the result-download URL. The script saves the files promptly and does not print the download URLs or key.
If a submission times out, investigate the request before resubmitting it. A missing client response does not prove that generation never started.
Check text and page counts
Save this as check.mjs:
import fs from 'node:fs/promises';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import {getDocument} from 'pdfjs-dist/legacy/build/pdf.mjs';
const root=path.dirname(fileURLToPath(import.meta.url));
export const normalize=text=>text.normalize('NFKC').replace(/\s+/gu,'');
export async function inspect(file,rule) {
const data=new Uint8Array(await fs.readFile(file));
const pdf=await getDocument({data,useSystemFonts:true,disableFontFace:true}).promise;
try {
let text='';
for(let i=1;i<=pdf.numPages;i++) {
const page=await pdf.getPage(i);const content=await page.getTextContent();
text+=content.items.filter(x=>'str' in x).map(x=>x.str).join(' ')+'\n';
}
const missing=rule.expect.filter(t=>!normalize(text).includes(normalize(t)));
const pagesOK=pdf.numPages>=rule.minPages&&pdf.numPages<=rule.maxPages;
return {ok:missing.length===0&&pagesOK,pages:pdf.numPages,missing,pagesOK};
} finally {await pdf.destroy();}
}
if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)) {
const cases=JSON.parse(await fs.readFile(path.join(root,'cases.json'),'utf8'));
const report={};let failed=false;
for(const [name,rule] of Object.entries(cases)) {
if(rule.negative)continue;
try {report[name]=await inspect(path.join(root,'results',`${name}.pdf`),rule);}
catch(error){report[name]={ok:false,error:error.message};}
failed||=!report[name].ok;
}
await fs.writeFile(path.join(root,'results','report.json'),JSON.stringify(report,null,2));
console.log(JSON.stringify(report,null,2));
process.exitCode=failed?1:0;
}
Then run node check.mjs. It writes results/report.json and exits with code 1 if a file cannot be read or a requirement fails.
The text normalization removes whitespace and applies Unicode NFKC normalization. That makes the check tolerant of line wrapping and some compatibility characters. It also means this check does not verify exact typography, whitespace, reading order, or every line of a table.
For stricter rules, change the normalization and add checks that match the document's purpose. Do not compare PDF binary hashes as a layout test: metadata changes can alter the bytes without altering the page.
Fix missing accents and symbols
The first multilingual export used an Arial-based fallback stack. It failed the requirements for FranΓ§ais, 23 Β°C, and β¬128.50. Its rendered output also showed incorrect characters. Changing the font selection to DejaVu Sans and generating again produced the expected text and visible glyphs in this sample.
Figure: excerpts from the generated PDFs before and after changing the font selection. The comparison shows the effect on this sample; it does not identify the underlying rendering cause.
A text check alone would not distinguish a PDF text-mapping issue from a visual glyph issue. Inspecting the rendered page gave a second piece of evidence. Conversely, a page can look readable while its extractable text is wrong, which matters when users copy, search, or further process the PDF.
Inspect page breaks and headings separately
The corrected parts list contained 60 rows across 3 pages, including its final marker. Review every page to see where rows end, whether characters are clipped, and whether the next page still makes sense.
The first version placed the column headings in <thead> and requested display: table-header-group. Those headings did not appear in the tested output. Moving the initial labels into a <tbody> row restored the first heading row; this example has not established repeated headings on later pages.
If repeating headings or a particular page margin is required, treat it as a separate acceptance condition. Do not infer it from the fact that the PDF has three pages. CSS rules are input intentions until the generated document shows that they took effect.
Figure: checks after correcting the font selection and initial table headings. Passing text checks does not certify the entire layout.
| Sample | Pages in this run | Required text | Further review |
|---|---|---|---|
| Short sheet | 1 | Passed | Look for clipping and check the price |
| Multilingual labels | 1 | Passed after font change | Inspect accents, Chinese glyphs, degree and euro symbols |
| Parts list | 3 | Passed after heading change | Check all rows and page boundaries; repeated headings remain a separate requirement |
Check that invalid exports fail
Remove <p>MODEL-Q7</p> from a copy of simple.html and save it as missing-model.html. Add this entry to cases.json:
"missing-model": {"expect":["MODEL-Q7","24 V","128.50 USD"],"minPages":1,"maxPages":1,"negative":true}
Generate the altered input explicitly with node generate.mjs missing-model. The normal report excludes cases marked negative; check the altered PDF against the original requirements:
// test-negative.mjs
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import { inspect } from './check.mjs';
const rules = JSON.parse(await fs.readFile('cases.json', 'utf8'));
const missing = await inspect('results/missing-model.pdf', rules.simple);
assert.equal(missing.ok, false);
assert.deepEqual(missing.missing, ['MODEL-Q7']);
const pages = await inspect('results/long-table.pdf', {...rules['long-table'], maxPages: 1});
assert.equal(pages.ok, false);
assert.equal(pages.pagesOK, false);
console.log('PASS: altered input and an invalid page range are both rejected.');
Run node test-negative.mjs. It should reject both the PDF with its model label removed and the three-page PDF checked against a one-page limit. These cases test the checker's failure paths.
The scripts were tested locally. To use them in CI, have the job fail when the checker returns a nonzero exit status, and keep visual review as a separate acceptance step.
Before accepting an export, require the generation response to succeed, the content checks to pass, and the rendered pages to meet the actual delivery requirements. Keep the sample HTML, downloaded PDFs, rules, and report together so the next change can be checked against the same expectations.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.

