PDF Report System - striae-org/striae GitHub Wiki
Striae uses a modular PDF report architecture in workers/pdf-worker so report formatting can be selected by name and extended for custom agency/user needs.
As of v3.0.1, the PDF worker no longer hardcodes report HTML in the worker entry file. Instead, report templates are implemented in dedicated modules and loaded by reportFormat.
As of v4.1.0, public browser requests through /api/pdf/* resolve reportFormat server-side in the Pages Function proxy (functions/api/pdf/[[path]].ts). The resolver uses the verified Firebase email and the primershear list fetched from the lists-worker (STRIAE_LISTS KV, key "primershear") to select either striae (default) or primershear.
- Keep PDF worker request handling separate from report formatting.
- Support multiple report layouts without duplicating worker runtime logic.
- Allow per-agency or per-user report selection with a stable API contract.
- Preserve backward compatibility with older clients.
The app sends a request envelope from app/components/actions/generate-pdf.ts.
Case-scoped audit trail PDF export is sent from app/components/actions/export-audit-pdf.ts and is triggered from the case audit viewer (UserAuditViewer / AuditViewerHeader Export PDF action).
For public browser traffic, the caller sends data only and does not select reportFormat client-side. Format selection is injected by the Pages proxy using the verified user identity.
Before sending the request, the caller converts browser-local image blobs/object URLs to data URLs so Cloudflare Browser Rendering can load report images without access to the browser's local object URL context. The caller also detects signed URL images (URLs containing ?st=) and pre-fetches them client-side, embedding the result as a data URL before the request is sent. This prevents the PDF worker's Puppeteer context from needing to make outbound requests for proxy-served signed URL images.
functions/api/pdf/[[path]].ts:
- Verifies Firebase bearer identity.
- Resolves
reportFormatusingidentity.emailand theprimershearlist from the lists-worker (env.LISTS_WORKERservice binding,GET /primershear). - Injects/overrides
reportFormatin the upstream payload before forwarding. - Forwards to PDF Worker via
env.PDF_WORKERservice binding.
workers/pdf-worker/src/pdf-worker.ts:
- Normalizes and resolves request shape.
- Selects format module by
reportFormat. - Dynamically imports the module from a loader map.
- Renders HTML from the selected module.
- Generates and returns PDF via Cloudflare Browser Rendering REST
/accounts/{ACCOUNT_ID}/browser-rendering/pdf. - Does not persist report payloads or generated PDF artifacts in worker-managed storage.
workers/pdf-worker/src/pdf-worker.ts requires:
-
ACCOUNT_IDfor account-scoped Browser Rendering endpoint routing -
BROWSER_API_TOKENwithBrowser Rendering - Editpermission
workers/pdf-worker/src/formats/format-striae.ts currently contains the Striae layout and exports:
import { ICON_256 } from '../assets/generated-assets';
export const renderReport: ReportRenderer = (data: PDFGenerationData): string => {
// Build and return HTML using embedded asset constants
};Report modules import image assets from ../assets/generated-assets (see Embedded Report Assets). This ensures all referenced images are self-contained in the worker bundle and never fetched at PDF render time.
Audit trail PDF rendering is implemented as a dedicated worker-dispatch path in workers/pdf-worker/src/pdf-worker.ts and workers/pdf-worker/src/pdf-worker.example.ts. When data.reportMode === "audit-trail", the dispatcher routes directly to workers/pdf-worker/src/audit-trail-report.ts, independent of selected reportFormat.
workers/pdf-worker/src/report-types.ts defines shared interfaces:
PDFGenerationDataPDFGenerationRequestAuditTrailReportPayloadReportRendererReportModule
The example worker keeps the environment-specific domain placeholder in:
workers/pdf-worker/src/pdf-worker.example.ts
It reuses shared non-sensitive modules:
workers/pdf-worker/src/formats/format-striae.tsworkers/pdf-worker/src/report-types.tsworkers/pdf-worker/src/assets/generated-assets.ts
Report modules should avoid loading images or logos over HTTP at render time because external fetch dependencies can fail (network errors, 403s, remote outages), producing missing assets or inconsistent output.
Instead, all images and logos used in report templates are embedded as base64 data URIs compiled into the worker bundle.
Place image files in:
workers/pdf-worker/src/assets/
Supported formats: .png, .jpg, .jpeg, .svg, .gif, .webp, .ico.
workers/pdf-worker/src/assets/generated-assets.ts is auto-generated and exports one named constant per asset file. Do not edit this file manually.
Naming convention: filename without extension → uppercase → non-alphanumeric characters become _
| Asset file | Exported constant |
|---|---|
icon-256.png |
ICON_256 |
brand-logo.svg |
BRAND_LOGO |
agency-mark.png |
AGENCY_MARK |
Run from workers/pdf-worker/:
npm run generate:assetsScript source: workers/pdf-worker/scripts/generate-assets.js
This scans src/assets/, encodes each file as a base64 data URI, and writes src/assets/generated-assets.ts.
import { ICON_256 } from '../assets/generated-assets';
export const renderReport: ReportRenderer = (data) => {
return `
<img src="${ICON_256}" alt="Striae icon" style="width: 14px; height: 14px;" />
`;
};- Copy the image file into
workers/pdf-worker/src/assets/. - Run
npm run generate:assetsto regeneratesrc/assets/generated-assets.ts. - Import the new constant from
../assets/generated-assetsin the relevant report module. - Deploy the worker:
npm run deploy.
Note:
src/assets/generated-assets.tsis committed to source control alongside the source assets insrc/assets/. Both must stay in sync — always regenerate after adding or replacing an asset.
Public browser callers (via /api/pdf/*) should send:
{
"data": {
"imageUrl": "...",
"caseNumber": "...",
"annotationData": {
"leftCase": "...",
"rightCase": "...",
"leftItem": "...",
"rightItem": "...",
"classType": "Bullet | Cartridge Case | Shotshell | Other",
"customClass": "...",
"classNote": "...",
"supportLevel": "ID | Exclusion | Inconclusive",
"bulletData": { "caliber": "...", "lgNumber": 6, "lgDirection": "right" },
"cartridgeCaseData": { "caliber": "...", "brand": "...", "hasFpDrag": true },
"shotshellData": { "gauge": "...", "brand": "...", "fpiShape": "oval" },
"additionalNotes": "...",
"includeConfirmation": true,
"confirmationData": { "fullName": "...", "badgeId": "..." },
"boxAnnotations": []
},
"activeAnnotations": [],
"userCompany": "...",
"userFirstName": "...",
"userLastName": "...",
"userBadgeId": "..."
}
}Only the bulletData, cartridgeCaseData, or shotshellData sub-object matching the active classType is expected; the others can be omitted. userBadgeId passes the authenticated analyst's Badge/ID to the report module for footer attribution.
The proxy resolves and injects reportFormat server-side.
Case audit trail export uses the same endpoint and adds reportMode plus auditTrailReport:
{
"data": {
"reportMode": "audit-trail",
"currentDate": "04/08/2026",
"caseNumber": "CASE-2026-001",
"userCompany": "Agency",
"userFirstName": "Analyst",
"userLastName": "Name",
"userBadgeId": "ABC123",
"auditTrailReport": {
"caseNumber": "CASE-2026-001",
"exportedAt": "2026-04-08T18:00:00.000Z",
"exportRangeStart": "2026-01-01T00:00:00.000Z",
"exportRangeEnd": "2026-04-08T18:00:00.000Z",
"chunkIndex": 1,
"totalChunks": 3,
"totalEntries": 542,
"includeRawJsonAppendix": true,
"entries": [
{
"timestamp": "...",
"userId": "...",
"userEmail": "...",
"action": "...",
"result": "...",
"details": {}
}
]
}
}
}Operational notes for this mode:
- Export is case-scoped and initiated only from the case audit viewer.
- Export intentionally uses full case history from case creation to current time and does not follow the active viewer filter controls.
- Imported archived cases with bundled audit data (
bundledAuditTrail.source = archive-bundle) are exported with all bundled entries. - The caller chunks large exports into multiple PDFs (part 1..N) to reduce timeout risk.
- The PDF output includes a raw JSON appendix for each entry to preserve full hidden details.
Direct/internal worker callers may send:
{
"reportFormat": "striae",
"data": {
"imageUrl": "...",
"caseNumber": "...",
"annotationData": {},
"activeAnnotations": []
}
}The worker still accepts legacy top-level payloads (without reportFormat and data) and treats them as report data.
If reportFormat is missing or blank, the worker defaults to:
striae
If reportFormat is unknown, the worker returns an error listing supported format names.
For browser requests through /api/pdf/*, any client-supplied reportFormat is overridden by the proxy resolver.
- Parse JSON body.
- Normalize
reportFormat(trim, lowercase). - If no valid format provided, use
striae. - Resolve
datafrom envelope or legacy payload. - Load module from
reportModuleLoaders. - Call module
renderReport(data). - If
data.reportMode = audit-trail, the worker dispatcher routes rendering to the audit trail report renderer before format-module selection. - Generate PDF from returned HTML.
The Striae report module (format-striae.ts) implements several layout and content behaviors that report authors extending to new formats should be aware of.
annotationData.additionalNotes is rendered in a dedicated section with:
-
white-space: pre-wrapandoverflow-wrap: anywhere— preserves line breaks and wraps long tokens. -
orphans: 3/widows: 3— reduces isolated lines at page boundaries. - Content is HTML-escaped before insertion (
escapeHtml()). - When the report also includes an image or a confirmation block, the notes section is placed on a new PDF page (
notes-pageCSS class) to keep evidence items visually separated.
Additional notes are also auto-populated by the Class Details modal: when an examiner saves class characteristics, buildClassDetailsSummary() appends a plain-text summary to additionalNotes. This means class characteristic details appear in reports even without a custom renderer for the sub-objects.
Class characteristic data (bulletData, cartridgeCaseData, shotshellData) is surfaced in the report through the additionalNotes summary appended by buildClassDetailsSummary(). The structured sub-objects are part of annotationData in the payload and are available for custom report modules that need direct field-level rendering.
When annotationData.includeConfirmation is true and confirmationData is present, the Striae format renders a confirmation block that includes:
{fullName}, {badgeId}
{confirmedByEmail} — {confirmedByCompany}
Confirmed at: {confirmedAt}
Confirmation ID: {confirmationId}
The analyst's own Badge/ID (userBadgeId) is also passed as a top-level PDFGenerationData field and is available for use in page headers, footers, or report attribution blocks in custom modules.
Box annotations are rendered as absolutely-positioned <div> elements over the evidence image using percentage-based coordinates (x, y, width, height). This makes box positioning device-independent regardless of image resolution. Each annotation uses the stored color (hex) as its border color. The luminance of the annotation color is checked at render time (needsLightBackground) to switch the overlay text between light and dark for readability.
The report-layout.ts buildRepeatedChromePdfOptions() function builds Puppeteer-compatible headerTemplate and footerTemplate HTML strings. The current Striae format uses:
- Header left: report generation date
- Header right: case number
- Header detail row: left/right case and item identifiers
- Footer left: "Notes formatted by Striae" with icon
-
Footer center: examiner company name (
userCompany) - Footer right: notes last-updated timestamp (if present)
Create workers/pdf-worker/src/formats/format-agency-abc.ts:
import type { PDFGenerationData, ReportRenderer } from '../report-types';
export const renderReport: ReportRenderer = (data: PDFGenerationData): string => {
const title = data.caseNumber || 'Case Report';
return `
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
body { font-family: Arial, sans-serif; padding: 24px; }
h1 { margin: 0 0 16px; }
</style>
</head>
<body>
<h1>Agency ABC Report</h1>
<p>${title}</p>
</body>
</html>`;
};Update workers/pdf-worker/src/pdf-worker.ts:
const reportModuleLoaders: Record<string, () => Promise<ReportModule>> = {
striae: () => import('./formats/format-striae'),
'agency-abc': () => import('./formats/format-agency-abc'),
};From the frontend or API caller:
const pdfRequest = {
reportFormat: 'agency-abc',
data: pdfData,
};For templates and onboarding consistency, update:
workers/pdf-worker/src/pdf-worker.example.ts- Add your new shared module (for example,
workers/pdf-worker/src/formats/format-agency-abc.ts) - Register that module in both loader maps (
pdf-worker.tsandpdf-worker.example.ts)
Current production strategy:
- Server-side email allowlist in the lists-worker (
STRIAE_LISTSKV, key"primershear") setsprimershearfor matching verified users; all others receivestriae.
Alternative strategies (when extending beyond the current resolver):
Map known userCompany values to format names in the app before sending the request.
Store preferred report format in user profile data and send that as reportFormat.
Store report format on the case metadata and use it when generating the report.
- Keep
reportFormatnames lowercase and stable. - Treat format names as part of your API contract.
- Add tests for request resolution and unknown format handling when extending formats.
- Keep report module output deterministic and avoid relying on browser globals.
- Do not use external image URLs in report module HTML. Use
../assets/generated-assetsconstants instead. - After adding or replacing any file in
src/assets/, runnpm run generate:assetsand commit both the asset file and the regeneratedsrc/assets/generated-assets.tsbefore deploying.
Cause: format name not present in reportModuleLoaders.
Fix: register the module and deploy updated worker.
Cause: invalid JSON payload or non-object body.
Fix: ensure Content-Type: application/json and valid object payload.
Cause: module returns invalid/empty HTML.
Fix: inspect module renderReport output and test with minimal HTML first.
Cause: report module references an external URL that fails at render time (for example network outage, blocked host, or denied fetch).
Fix: place the image in workers/pdf-worker/src/assets/, run npm run generate:assets, and import the generated constant from ../assets/generated-assets instead of using a URL.
functions/api/pdf/[[path]].tsworkers/pdf-worker/src/pdf-worker.tsworkers/pdf-worker/src/formats/format-striae.tsworkers/pdf-worker/src/formats/format-primer-shear.tsworkers/pdf-worker/src/report-types.tsworkers/pdf-worker/src/pdf-worker.example.ts-
workers/pdf-worker/src/assets/generated-assets.ts(auto-generated — do not edit manually) -
workers/pdf-worker/src/assets/(source images for asset generation) -
workers/pdf-worker/scripts/generate-assets.js(asset generation script) app/components/actions/generate-pdf.ts