Skip to content

Instantly share code, notes, and snippets.

@nfarina
Created February 13, 2026 17:58
Show Gist options
  • Select an option

  • Save nfarina/9513e52cb8ab8a9ad90c2544fc236d7a to your computer and use it in GitHub Desktop.

Select an option

Save nfarina/9513e52cb8ab8a9ad90c2544fc236d7a to your computer and use it in GitHub Desktop.
Convert HTML to PDF
import Archiver from "archiver";
import { Seconds } from "crosswing/shared/timespan";
import { wait } from "crosswing/shared/wait";
import { AppPaths } from "db/paths/AppPaths";
import { HttpPaths } from "db/paths/HttpPaths";
import {
getTemplateVariations,
Template,
TemplateExportFormat,
TemplateExportLayout,
TemplateVariation,
} from "db/templates";
import Debug from "debug";
import type { Response } from "express";
import { https, runWith } from "firebase-functions/v1";
import { UserFacingError } from "firewing/admin/functions";
import fs from "fs/promises";
import type { Page } from "puppeteer";
import {
isRunningLocally,
isRunningUnderEmulator,
serverUrls,
} from "../../util/env";
import { extractImages } from "../extractImages";
import { Templates } from "../Templates";
const debug = Debug("cloud:templates");
/**
* A cloud function that serves an exported PDF, image, or ZIP of a generated
* template using Puppeteer.
*
* This is still using Firebase Functions v1! I tried v2 but Cloud Run doesn't
* have the deps needed for Puppeteer and you have to mess with Docker stuff
* and I ran out of time. https://pptr.dev/troubleshooting#running-puppeteer-on-google-cloud-run
*
* To test this locally, you must visit the direct function URL. Examples:
* http://127.0.0.1:2353/burnside-web/us-central1/templates?template=WYCxlC5wfsZEaTHB5qlX
* http://127.0.0.1:2373/burnside-web/us-central1/templates?template=template72
*/
export const serveTemplate = runWith({
timeoutSeconds: isRunningLocally() ? 540 : 60,
memory: "4GB",
}).https.onRequest(async (req, res) => {
try {
const templateId = stringParam(req, "template");
const format = stringParam(req, "format", "png") as TemplateExportFormat;
const layout = stringParam(req, "layout", "twoUp") as TemplateExportLayout;
const dpr = Number(stringParam(req, "dpr", "2"));
const includeVariations =
stringParam(req, "includeVariations", "false") === "true";
const meta = stringParam(req, "meta", "false") === "true";
// Enable CORS.
res.setHeader("Access-Control-Allow-Origin", "*");
if (!templateId) {
res.status(400).send("Bad Request: missing template ID");
return;
}
const template = await Templates.getOne({ templateId });
if (format === "zip") {
await serveTemplateZip({ template, includeVariations, meta, res });
} else {
await serveTemplateUsingPuppeteer({
template,
layout,
format,
dpr,
meta,
res,
});
}
} catch (error: any) {
debug("Error serving template:", error.stack);
if (!res.headersSent) {
res.status(500).send(error.message);
} else {
res.end();
}
}
});
async function serveTemplateZip({
template: mainTemplate,
includeVariations,
meta,
res,
}: {
template: Template;
includeVariations: boolean;
meta: boolean;
res: Response;
}) {
// Make our API server render the template (we publish this code,
// `serveTemplate`, infrequently, so we want to keep the render code fresh
// and outsource it to the frequently-published API server).
const host = serverUrls().cloud;
// Make one ZIP with the main template plus all variations (if using variations).
const zip = Archiver("zip");
const variations = includeVariations
? getTemplateVariations(mainTemplate.variations)
: [];
const imagesAdded = new Set<string>();
const addTemplate = async (variation: TemplateVariation | null) => {
const template = await (async () => {
if (variation) {
if (!variation.templateId) {
throw new UserFacingError(
`Variation "${variation.name ?? variation.id}" was not generated.`,
);
}
return Templates.getOne({ templateId: variation.templateId });
}
return mainTemplate;
})();
const url = `${host}${HttpPaths.templateHtml({
templateId: template.id,
meta,
})}`;
// Fetch the rendered HTML.
const response = await fetch(url);
const html = await response.text();
// Now we need to find all the images in the HTML and fetch them.
const extracted = await extractImages({ template, html });
// Add the rendered and transformed HTML to the ZIP archive.
let htmlPath = `index.html`;
if (variation) {
htmlPath = `${variation.name ?? variation.id}.html`;
} else if (variations.length > 0 && template.mainVariationName) {
htmlPath = `${template.mainVariationName}.html`;
}
zip.append(extracted.html, { name: htmlPath });
for (const image of extracted.images) {
const { localPath, sourceUrl } = image;
if (imagesAdded.has(localPath)) {
// Skip duplicate images.
continue;
}
// Download the image.
const response = await fetch(sourceUrl);
const buffer = await response.arrayBuffer();
// Add the image to the ZIP archive.
zip.append(Buffer.from(buffer), { name: localPath });
imagesAdded.add(localPath);
}
};
await addTemplate(null);
for (const variation of variations) {
await addTemplate(variation);
}
zip.finalize();
// Send the ZIP archive to the client. We don't stream it as we download
// images in case we encounter an error and need to send an error response.
res.setHeader("Content-Type", "application/zip");
res.setHeader("Content-Disposition", `attachment; filename="template.zip"`);
zip.pipe(res);
}
async function serveTemplateUsingPuppeteer({
template,
layout,
format,
dpr,
meta,
res,
}: {
template: Template;
layout: TemplateExportLayout;
format: TemplateExportFormat;
dpr: number;
meta: boolean;
res: Response;
}) {
const { id: templateId } = template;
// We import puppeteer dynamically instead of importing it at the top of the
// file because we only want Puppeteer to be installed for the "templates"
// function. Cloud Build will complain if we import it statically and it's not
// in our generated package.json. See gen/dist.ts.
const puppeteer = await import("puppeteer");
const browser = await puppeteer.launch({
...(isRunningLocally()
? {
timeout: Seconds(540),
// headless: false,
}
: null),
});
const page = await browser.newPage();
if (layout === "twoUp" || layout === "single") {
page.setViewport({
// Match A4 dimensions.
width: 800,
height: 1123,
// Block format can get quite large and our notification emails don't need large images.
deviceScaleFactor: format === "blocks" ? 1 : dpr,
});
} else if (layout === "openGraph") {
// Export a square image for social media.
page.setViewport({ width: 1000, height: 1000 });
}
await page.emulateMediaFeatures([
{ name: "prefers-color-scheme", value: "light" },
]);
// We used to render PDFs as best we could by going to the export page with
// the actual iframes and calling pdf() on the page, but Chrome renders the
// "mobile layout" iframe in a way that doesn't trigger the media queries for
// mobile layout. So we have to render the image first and then render the
// PDF from that image. It's not a "real" PDF but non-devs won't notice.
const forceImagePDF = format === "pdf" && layout === "twoUp";
if (layout === "twoUp" || format === "blocks") {
// To render a "twoUp" layout, we need to go to a React-based page that
// creates two iframes, one for desktop and one for mobile.
// We need that same React page to render in blocks format, becuase we need
// our JS that attaches the data-block-id attributes to the correct elements
// based on the injected HTML comments in renderTemplate().
// We'll go to Vite if in development, or the production server if in
// production.
const url = `${serverUrls().app}${AppPaths.sharedTemplateExport({
templateId,
layout,
format: forceImagePDF ? "png" : format,
dpr,
meta,
// If we're running under an emulator, we need to pass just enough of
// an Emulators object to make the URL builder set the emulator param.
emulators: isRunningUnderEmulator() ? { firestore: {} as any } : {},
})}`;
debug(`Navigating to ${url}`);
await page.goto(url);
if (layout === "twoUp") {
debug("Waiting for an iframe to load");
// Wait for at least one of the iframes to load, that way we can be sure
// other resources will have at least started loading.
await page.waitForSelector("iframe");
}
} else {
// For any other layouts, we can skip the Vite/React stuff and go directly
// to the server API for the template HTML.
const url = `${serverUrls().cloud}${HttpPaths.templateHtml({
templateId,
dpr,
meta,
})}`;
debug(`Navigating to ${url}`);
await page.goto(url);
}
debug("Waiting for network idle");
await page.waitForNetworkIdle({ concurrency: 2 });
debug("Network is idle. Waiting 2 seconds");
await wait(Seconds(2));
// Capture an image of the whole template first.
const image = await page.screenshot({
fullPage: layout === "twoUp" || layout === "single",
captureBeyondViewport: true,
});
// Uncomment this to pause the page for debugging.
// await pause(page);
if (format === "png") {
// You just want the image? Cool, we're done here.
await browser.close();
res.setHeader("Content-Type", "image/png");
res.end(Buffer.from(image));
return;
}
if (forceImagePDF) {
// You want an image-based PDF? OK, wrap that taco in a pizza.
// https://www.youtube.com/watch?v=evUWersr7pc
// First save the image to /tmp/.
const pngPath = `/tmp/${templateId}.png`;
await fs.writeFile(pngPath, image);
// Now load the image into a new page.
const pdfPage = await browser.newPage();
pdfPage.setViewport({
// Match A4 dimensions.
width: 800,
height: 1123,
deviceScaleFactor: 2,
});
await pdfPage.goto(`file://${pngPath}`);
// await pause();
const pdf = await pdfPage.pdf({
format: "A4",
printBackground: true,
});
await browser.close();
res.setHeader("Content-Type", "application/pdf");
res.end(Buffer.from(pdf));
return;
} else if (format === "pdf") {
// Otherwise we can give you a "real" PDF!
const pdf = await page.pdf({
format: "A4",
printBackground: true,
});
await browser.close();
res.setHeader("Content-Type", "application/pdf");
res.end(Buffer.from(pdf));
return;
}
if (format === "blocks") {
// You want the blocks? OK, we'll give you the blocks. You'll receive
// a ZIP file with a separate image for each block, where each block is
// named by its blockId, plus a full.png image of the whole template.
// await pause(page);
debug("Looking for blocks");
// First, we need to find all the blocks in the template.
const blocks = await page.evaluate(() => {
return Array.from(document.querySelectorAll("[data-block-id]")).map(
(el) => {
const { x, y, width, height } = el.getBoundingClientRect();
const boundingBox = { x, y, width, height };
if (x < 0 || y < 0 || width <= 0 || height <= 0) {
console.log(
"Skipping block",
el,
"with invalid bounding box",
boundingBox,
);
return null;
}
return {
blockId: el.getAttribute("data-block-id"),
boundingBox,
};
},
);
});
const zip = Archiver("zip");
// Add the full template image to the ZIP archive.
zip.append(Buffer.from(image), { name: "full.png" });
for (const block of blocks) {
if (!block) continue; // Skip invalid blocks.
const { blockId, boundingBox } = block;
const blockImage = await page.screenshot({ clip: boundingBox });
// Add the block image to the ZIP archive.
zip.append(Buffer.from(blockImage), { name: `${blockId}.png` });
}
zip.finalize();
debug("Sending ZIP archive to client");
// Send the ZIP archive to the client.
res.setHeader("Content-Type", "application/zip");
res.setHeader("Content-Disposition", `attachment; filename="blocks.zip"`);
zip.pipe(res);
await browser.close();
return;
}
throw new Error(`Unsupported format: ${format}`);
}
async function pause(page: Page) {
await page.waitForSelector(".doesnt-exist", { timeout: Seconds(540) });
}
function stringParam(
req: https.Request,
key: string,
defaultValue?: string,
): string | undefined {
const value = req.query[key];
if (value && typeof value === "string") {
return value;
} else if (value === undefined) {
return defaultValue;
}
// Express will parse crazy query parameters like "w[0]=100&w[1]=200" into
// an array, or "?a[x]=b&a[y]=c" into an object. We don't support that.
throw new Error(`Expected query parameter "${key}" to be a string`);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment