PDF generation and the rendering service
Which edge functions produce PDFs, how the browser-rendered ones work, and how to debug them. For engineers changing a generator or operating the rendering service. How staff use these documents is in documents and filing; how generated files are stored, versioned and shared is in documents.
The generators
Every generator is an edge function that authorizes the caller with a typed permission key, checks project ownership through a caller-scoped (RLS) read, does its reads with a service-role client, and files the result through insertDocument (_shared/documents.ts), which writes the documents row and the Storage object.
| Function | Permission | Produces (document code) | Renderer |
|---|---|---|---|
generate-xrf-report | perform_field_work | XRF inspection report (INSP-RPT) and its certification sheet (XRF-AFF) | Browser render plus pdf-lib overlays |
generate-proposal-pdf | manage_proposals | Service proposal (PROPOSAL) plus proposals and proposal_line_items rows | Browser render |
generate-paint-chip-report | perform_field_work | Sampling report (PC-RPT), affidavit (PC-AFF), lab results sheet (PC-LAB) | pdf-lib |
generate-dust-wipe-report | perform_field_work | Dust-wipe lab sheet (DUST-LAB) | pdf-lib |
generate-abatement-report | perform_abatement_work | Abatement component listing (ABATE-RPT) | pdf-lib |
generate-coc | perform_field_work | Chain-of-custody form (COC), one document per sample batch; regenerating files a new version | pdf-lib |
generate-invoice-pdf | manage_invoices | Invoice, with invoices and invoice_line_items rows and an atomically allocated invoice number | pdf-lib |
generate-hpd-document | manage_documents | The HPD response form for one decided form bundle | pdf-lib |
assemble-filing-package | manage_documents | A filing_packages row linking every generated HPD document and affidavit for the project | none (links existing documents) |
Report generation is available for XRF, paint-chip, dust-wipe and abatement visits; only the XRF report has a version-chain table (xrf_report_versions).
The pdf-lib generators share PdfWriter and the tenant letterhead helpers drawLetterheadHeader / drawLetterheadFooter in branding.ts. The browser-rendered documents (XRF report, proposal) exist because their design is a stylesheet: cards, pills and webfonts are only faithful when a real browser lays them out.
Browser-rendered pipeline
- Model. Every figure and cell comes from one call to
buildXrfReportModelindomain/src/field/xrf/reportModel.ts, which calls the samesummarizeDeterminationsthe ingest preview uses, so the wizard and the PDF cannot disagree. Classification is recomputed from mg/cm² rather than read from the device's verdict column. Nothing in the function or the HTML builder classifies or counts a reading; new derived figures belong in the model, with a unit test. - HTML.
buildXrfReportHtml.tsemits fixed US Letter pages withoverflow: hidden.xrfReportLayout.tspacks rows into pages using measured heights and tells the builder which page and rectangle each overlay belongs in. Fonts (Space Grotesk, DM Sans) load from Google Fonts, so the renderer needs internet access. - Render.
renderHtmlToPdfsends the whole document as one JSON body in a single call and gets PDF bytes back. It refuses a PDF whose page count differs from the plan, because the overlays would land on the wrong pages; that error means a page overflowed. - Overlays (pdf-lib). A browser will not print a nested PDF, and phone photos are several MB, so
renderXrfReportViaBrowser.tsdraws these onto the rendered pages: the filed floor plan on the Apartment Sketch page, the inspector's and firm's certificates on their licence pages, up to five inspection photos, and the firm's component diagram. Floor plans may be PDF, PNG or JPEG, detected by magic bytes rather than stored content type; a plan that is filed but cannot be drawn (HEIC, for example) fails the report with a named reason instead of issuing an empty sketch box. Missing licence or asset files drop that page and are recorded as warnings, not failures. - File. The report is written as
INSP-RPT; the certification sheet is copied out of the same render asXRF-AFF. A newxrf_report_versionsrow supersedes the previous latest one, anxrf_audit_findingsrow records warnings and QA findings, and anxrf_report_reviewrow (pending) is created for the first version.
The proposal follows the same route with less: one render call, no overlays (renderProposalPdfViaBrowser.ts). Its @font-face rules resolve against the public report-fonts bucket, whose base URL comes from reportFontBaseUrl.
What blocks an XRF report
A report is refused with HTTP 422 report_blocked and a blockers list when the calibration cadence, sequence-gap or time-span checks fail (the same Tier-A checks ingest-xrf-csv applies at ingest), or when no floor-plan drawing (final_path or sketch_path) exists for the project. A blocked real attempt writes an xrf_audit_findings row. Room and component coverage, licence validation failures and asset gaps never block; they are recorded as audit findings for the reviewer.
Preview mode
{ inspectionId, preview: true } renders the identical PDF and returns the bytes with X-Report-Warnings and X-Report-Pages headers. It writes no documents, versions, findings or review rows and changes no status, so the wizard can show the real document before anyone issues it.
No fallback renderer
A failed render fails the report. If neither BROWSER_RENDER_ENDPOINT nor the Cloudflare credentials are set, renderHtmlToPdf throws and the function answers 500 with the underlying message (report rendering failed (browser rendering | report assembly): ..., code: "render_failed").
A hand-drawn pdf-lib fallback would produce a visibly different document (Helvetica, none of the card and pill treatments a client signs off) while reporting success, with only a console.warn as a signal. A misconfigured environment must fail loudly instead, so do not add a fallback as a fix.
The status is 500, not 502, on purpose: the client's describeEdgeFunctionError treats 502, 503 and 504 as gateway noise and replaces the body with generic wording, which would hide the message this failure exists to show.
Rendering service
renderHtmlToPdf picks its target in this order:
| Environment | Target |
|---|---|
BROWSER_RENDER_ENDPOINT (optional BROWSER_RENDER_TOKEN) | POST there. Any service that accepts Cloudflare's request shape and returns PDF bytes works. |
CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN | Cloudflare Browser Rendering .../browser-rendering/pdf |
| neither | Throws; the report fails |
Behaviour worth knowing:
- Request shape.
gotoOptions.waitUntil = "networkidle0"so webfonts land before printing.pdfOptions.formatmust be lowercase ("letter","a4"): Cloudflare rejects"A4"with a 400, and the local sidecar ignoresformat, so this only fails against the real service. The cover-style pages usepreferCSSPageSizewith an explicit size in points instead. - Rate limits. A 429, and only a 429, is retried with a jittered delay of about 11 to 25 seconds (honouring
Retry-After, clamped), within a 90 s budget sized against Supabase's 150 s wall clock and idle timeout. The jitter is required when calls are simultaneous. A plan with headroom never returns 429, so the retry never waits. Cloudflare's free plan allows one Browser Rendering request per 10 seconds, so two reports in quick succession can be slow; Workers Paid removes the limit and is the fix for reports failing under load, not code. - Timeout. Each request aborts after 60 s.
- Token permission. The Cloudflare token needs Browser Rendering: Edit (the token picker calls it "Browser Run").
- What crosses the wire. The HTML carries site address, inspector licence number and every reading. It is a transient render with nothing stored, but it is client data reaching a third party. To keep it in-house, run any headless-Chrome service and set
BROWSER_RENDER_ENDPOINT; nothing else changes.
Set production credentials with npx supabase secrets set CLOUDFLARE_ACCOUNT_ID=... CLOUDFLARE_API_TOKEN=... per environment (deployment). Local setup of the sidecar is in local development.
Assets and branding
| Source | Used for |
|---|---|
tenant_branding row and the branding bucket | Company name, colours, footer text, contact details, wordmark and square logo. Resolved for every generator by resolveTenantBranding. A missing or unreadable logo drops the logo, never the document. |
xrf_report_assets rows and the report-assets bucket | Per-tenant cover image, contents image and component diagram for the XRF report; a null-tenant row is a shared fallback except for the component diagram, which only a tenant's own upload replaces. Also the EPA firm certificate, used only when the tenant has no licence file. |
Licence packet (_shared/licenses.ts) | The tenant's EPA certification and the assigned inspector's licence, with the checks run against the inspection date. |
report-fonts bucket | Webfonts for the proposal's @font-face. Populate with scripts/upload-report-fonts.mjs. |
field-photos bucket | Floor-plan drawings and inspection photos read by the XRF report. |
xrfReportAssets.ts | The design's built-in imagery as base64. Generated by scripts/sync-xrf-report-assets.mjs from reportHtml/xrfAssets/; never hand-edit it. |
Debugging
Start with the xrf-report-debug skill, which walks the same path:
- Read the function's own log (
npx supabase functions logs generate-xrf-report --local, or without--localfor the linked project). A generic message in the browser means a gateway layer, not this function. - Check which target applies (
supabase/functions/.envlocally, project secrets in production). - Match the symptom: "report generator service is not responding" is the sidecar bound to
127.0.0.1(usenpm run render:local); a wrong typeface locally means the sidecar or font upload was skipped; a 429 in production is the rate limit; a named plan-format failure is an unsupported floor-plan file; a page-count error is an overflowing page.
Layout changes live in buildXrfReportHtml.ts. Because xrfReportLayout.ts uses measured heights, changing a row, heading or notes style means re-measuring, or rows clip off the page. Overlays are positioned by rectangles the builder returns, so a misplaced overlay is a geometry constant there, not a pdf-lib bug.
Tests
_shared/reportHtml/buildXrfReportHtml.test.ts, floorPlanFormat.test.ts and _shared/browserRender.test.ts (retry delay) cover the builder and renderer helpers; the report model and classification are covered in domain/tests/, including the legacy-fidelity suite described in conventions.