Skip to main content

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.

FunctionPermissionProduces (document code)Renderer
generate-xrf-reportperform_field_workXRF inspection report (INSP-RPT) and its certification sheet (XRF-AFF)Browser render plus pdf-lib overlays
generate-proposal-pdfmanage_proposalsService proposal (PROPOSAL) plus proposals and proposal_line_items rowsBrowser render
generate-paint-chip-reportperform_field_workSampling report (PC-RPT), affidavit (PC-AFF), lab results sheet (PC-LAB)pdf-lib
generate-dust-wipe-reportperform_field_workDust-wipe lab sheet (DUST-LAB)pdf-lib
generate-abatement-reportperform_abatement_workAbatement component listing (ABATE-RPT)pdf-lib
generate-cocperform_field_workChain-of-custody form (COC), one document per sample batch; regenerating files a new versionpdf-lib
generate-invoice-pdfmanage_invoicesInvoice, with invoices and invoice_line_items rows and an atomically allocated invoice numberpdf-lib
generate-hpd-documentmanage_documentsThe HPD response form for one decided form bundlepdf-lib
assemble-filing-packagemanage_documentsA filing_packages row linking every generated HPD document and affidavit for the projectnone (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​

  1. Model. Every figure and cell comes from one call to buildXrfReportModel in domain/src/field/xrf/reportModel.ts, which calls the same summarizeDeterminations the 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.
  2. HTML. buildXrfReportHtml.ts emits fixed US Letter pages with overflow: hidden. xrfReportLayout.ts packs 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.
  3. Render. renderHtmlToPdf sends 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.
  4. Overlays (pdf-lib). A browser will not print a nested PDF, and phone photos are several MB, so renderXrfReportViaBrowser.ts draws 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.
  5. File. The report is written as INSP-RPT; the certification sheet is copied out of the same render as XRF-AFF. A new xrf_report_versions row supersedes the previous latest one, an xrf_audit_findings row records warnings and QA findings, and an xrf_report_review row (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:

EnvironmentTarget
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_TOKENCloudflare Browser Rendering .../browser-rendering/pdf
neitherThrows; the report fails

Behaviour worth knowing:

  • Request shape. gotoOptions.waitUntil = "networkidle0" so webfonts land before printing. pdfOptions.format must be lowercase ("letter", "a4"): Cloudflare rejects "A4" with a 400, and the local sidecar ignores format, so this only fails against the real service. The cover-style pages use preferCSSPageSize with 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​

SourceUsed for
tenant_branding row and the branding bucketCompany 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 bucketPer-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 bucketWebfonts for the proposal's @font-face. Populate with scripts/upload-report-fonts.mjs.
field-photos bucketFloor-plan drawings and inspection photos read by the XRF report.
xrfReportAssets.tsThe 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:

  1. Read the function's own log (npx supabase functions logs generate-xrf-report --local, or without --local for the linked project). A generic message in the browser means a gateway layer, not this function.
  2. Check which target applies (supabase/functions/.env locally, project secrets in production).
  3. Match the symptom: "report generator service is not responding" is the sidecar bound to 127.0.0.1 (use npm 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.