Created
February 13, 2026 17:58
-
-
Save nfarina/9513e52cb8ab8a9ad90c2544fc236d7a to your computer and use it in GitHub Desktop.
Convert HTML to PDF
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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