patx/makeitpdf

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 });
  }
});