const test = require("node:test");
const assert = require("node:assert/strict");
const http = require("node:http");
const fs = require("node:fs/promises");
const path = require("node:path");
const os = require("node:os");
const zlib = require("node:zlib");
const { chromium } = require("playwright-core");
const XLSX = require("xlsx");
const JSZip = require("jszip");
const { PDFDocument: LibPDFDocument, StandardFonts } = require("pdf-lib");
const ROOT = path.resolve(__dirname, "..");
function crc32(buffer) {
let crc = 0xffffffff;
for (const byte of buffer) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
}
const output = Buffer.alloc(4);
output.writeUInt32BE((crc ^ 0xffffffff) >>> 0);
return output;
}
function pngChunk(type, data) {
const name = Buffer.from(type);
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length);
return Buffer.concat([length, name, data, crc32(Buffer.concat([name, data]))]);
}
function makePng(width, height) {
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header.set([8, 6, 0, 0, 0], 8);
const row = Buffer.alloc(width * 4 + 1);
for (let x = 0; x < width; x += 1) row.set([244, 64, 45, 255], 1 + x * 4);
const pixels = Buffer.concat(Array.from({ length: height }, () => row));
return Buffer.concat([
signature,
pngChunk("IHDR", header),
pngChunk("IDAT", zlib.deflateSync(pixels)),
pngChunk("IEND", Buffer.alloc(0)),
]);
}
async function makeDocx() {
const zip = new JSZip();
zip.file("[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
</Types>`);
zip.folder("_rels").file(".rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>`);
zip.folder("word").file("document.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body><w:p><w:r><w:t>Searchable Word phrase</w:t></w:r></w:p><w:sectPr/></w:body>
</w:document>`);
zip.folder("word").folder("_rels").file("document.xml.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>`);
return zip.generateAsync({ type: "nodebuffer" });
}
function makeXlsx() {
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([
["Product", "Amount"],
["Searchable workbook phrase", 42],
]), "Visible sheet");
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet([["Must stay hidden"]]), "Hidden sheet");
workbook.Workbook = { Sheets: [
{ name: "Visible sheet", Hidden: 0 },
{ name: "Hidden sheet", Hidden: 1 },
] };
return XLSX.write(workbook, { type: "buffer", bookType: "xlsx" });
}
async function makePdf(text, width, height) {
const document = await LibPDFDocument.create();
const font = await document.embedFont(StandardFonts.Helvetica);
const page = document.addPage([width, height]);
page.drawText(text, { x: 18, y: height - 32, size: 12, font });
return Buffer.from(await document.save());
}
function mimeFor(filename) {
const extension = path.extname(filename);
return {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".woff2": "font/woff2",
".ttf": "font/ttf",
".ttc": "font/collection",
}[extension] || "application/octet-stream";
}
async function startServer() {
const server = http.createServer(async (request, response) => {
try {
const pathname = decodeURIComponent(new URL(request.url, "http://localhost").pathname);
const relative = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, "");
const filename = path.resolve(ROOT, relative);
if (!filename.startsWith(ROOT + path.sep)) throw new Error("outside root");
const body = await fs.readFile(filename);
response.writeHead(200, { "content-type": mimeFor(filename), "cache-control": "no-store" });
response.end(body);
} catch {
response.writeHead(404).end("Not found");
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
return { server, origin: `http://127.0.0.1:${server.address().port}` };
}
test("the browser combines PDFs and converted files in queue order", { timeout: 120000 }, async () => {
const { server, origin } = await startServer();
const browser = await chromium.launch({
executablePath: "/usr/bin/google-chrome",
headless: true,
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const temp = await fs.mkdtemp(path.join(os.tmpdir(), "makeitpdf-browser-"));
const page = await browser.newPage({ acceptDownloads: true });
const externalRequests = [];
const pageErrors = [];
page.on("request", (request) => {
if (!request.url().startsWith(origin) && !request.url().startsWith("blob:")) {
externalRequests.push(request.url());
}
});
page.on("pageerror", (error) => pageErrors.push(error.message));
try {
await page.goto(origin, { waitUntil: "networkidle" });
await page.setInputFiles("#picker", [
{ name: "opening.pdf", mimeType: "application/pdf", buffer: await makePdf("Searchable opening PDF phrase", 216, 144) },
{ name: "original-size.png", mimeType: "image/png", buffer: makePng(100, 50) },
{ name: "sample.docx", mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", buffer: await makeDocx() },
{ name: "sample.xlsx", mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", buffer: makeXlsx() },
{ name: "notes.md", mimeType: "text/markdown", buffer: Buffer.from(
"# Notes\n\nSearchable Markdown phrase.\n\nمرحبا بالعالم\n\nשלום עולם\n\nनमस्ते दुनिया\n\n中文\n\n日本語\n\n한국어"
) },
{ name: "records.csv", mimeType: "text/csv", buffer: Buffer.from("Name,Value\nSearchable CSV phrase,7") },
{ name: "closing.pdf", mimeType: "application/pdf", buffer: await makePdf("Searchable closing PDF phrase", 300, 180) },
]);
await page.getByText("7 files", { exact: false }).waitFor();
assert.equal(await page.textContent("#build"), "Combine into one PDF");
await page.click("#build");
await page.locator("#done:not([hidden])").waitFor({ timeout: 90000 });
const [download] = await Promise.all([page.waitForEvent("download"), page.click("#download")]);
const pdfPath = path.join(temp, "mixed.pdf");
await download.saveAs(pdfPath);
assert.deepEqual(pageErrors, []);
assert.deepEqual(externalRequests, []);
const pdfData = new Uint8Array(await fs.readFile(pdfPath));
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
const pdf = await pdfjs.getDocument({ data: pdfData, disableWorker: true }).promise;
assert.ok(pdf.numPages >= 7);
const firstPage = await pdf.getPage(1);
const viewport = firstPage.getViewport({ scale: 1 });
assert.ok(Math.abs(viewport.width - 216) < 0.1, `expected 216pt, got ${viewport.width}`);
assert.ok(Math.abs(viewport.height - 144) < 0.1, `expected 144pt, got ${viewport.height}`);
const imagePage = await pdf.getPage(2);
const imageViewport = imagePage.getViewport({ scale: 1 });
assert.ok(Math.abs(imageViewport.width - 75) < 0.1, `expected 75pt, got ${imageViewport.width}`);
assert.ok(Math.abs(imageViewport.height - 37.5) < 0.1, `expected 37.5pt, got ${imageViewport.height}`);
const closingPage = await pdf.getPage(pdf.numPages);
const closingViewport = closingPage.getViewport({ scale: 1 });
assert.ok(Math.abs(closingViewport.width - 300) < 0.1, `expected 300pt, got ${closingViewport.width}`);
assert.ok(Math.abs(closingViewport.height - 180) < 0.1, `expected 180pt, got ${closingViewport.height}`);
const pageText = [];
for (let index = 1; index <= pdf.numPages; index += 1) {
const content = await (await pdf.getPage(index)).getTextContent();
pageText.push(content.items.map((item) => item.str).join(" "));
}
const extracted = pageText.join("\n");
assert.match(pageText[0], /Searchable opening PDF phrase/);
assert.match(pageText.at(-1), /Searchable closing PDF phrase/);
assert.match(extracted, /Searchable Word phrase/);
assert.match(extracted, /Searchable workbook phrase/);
assert.match(extracted, /Searchable Markdown phrase/);
assert.match(extracted, /Searchable CSV phrase/);
assert.match(extracted, /[\u0600-\u06ff]{5}/);
assert.match(extracted, /[\u0590-\u05ff]{4}/);
assert.match(extracted, /नमस्ते/);
assert.match(extracted, /中文/);
assert.match(extracted, /日本語/);
assert.match(extracted, /한국어/);
assert.doesNotMatch(extracted, /Must stay hidden/);
await page.click("#restart");
await page.setInputFiles("#picker", [
{ name: "one.pdf", mimeType: "application/pdf", buffer: await makePdf("PDF-only first phrase", 240, 160) },
{ name: "two.pdf", mimeType: "application/pdf", buffer: await makePdf("PDF-only second phrase", 320, 200) },
]);
await page.getByText("2 files", { exact: false }).waitFor();
assert.equal(await page.textContent("#build"), "Combine PDFs");
await page.click("#build");
await page.locator("#done:not([hidden])").waitFor({ timeout: 90000 });
const [pdfOnlyDownload] = await Promise.all([
page.waitForEvent("download"),
page.click("#download"),
]);
const pdfOnlyPath = path.join(temp, "pdf-only.pdf");
await pdfOnlyDownload.saveAs(pdfOnlyPath);
const pdfOnly = await pdfjs.getDocument({
data: new Uint8Array(await fs.readFile(pdfOnlyPath)),
disableWorker: true,
}).promise;
assert.equal(pdfOnly.numPages, 2);
const pdfOnlyText = [];
for (let index = 1; index <= pdfOnly.numPages; index += 1) {
const content = await (await pdfOnly.getPage(index)).getTextContent();
pdfOnlyText.push(content.items.map((item) => item.str).join(" "));
}
assert.match(pdfOnlyText[0], /PDF-only first phrase/);
assert.match(pdfOnlyText[1], /PDF-only second phrase/);
} finally {
await browser.close();
await new Promise((resolve) => server.close(resolve));
await fs.rm(temp, { recursive: true, force: true });
}
});