PDF Report System - striae-org/striae GitHub Wiki

Overview

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.

Goals

  • 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.

Current Architecture

1. Caller (frontend action)

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.

1.5 Pages proxy format resolver

functions/api/pdf/[[path]].ts:

  • Verifies Firebase bearer identity.
  • Resolves reportFormat using identity.email and the primershear list from the lists-worker (env.LISTS_WORKER service binding, GET /primershear).
  • Injects/overrides reportFormat in the upstream payload before forwarding.
  • Forwards to PDF Worker via env.PDF_WORKER service binding.

2. Worker entry (dispatcher)

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.

2.1 Runtime credentials

workers/pdf-worker/src/pdf-worker.ts requires:

  • ACCOUNT_ID for account-scoped Browser Rendering endpoint routing
  • BROWSER_API_TOKEN with Browser Rendering - Edit permission

3. Report module(s)

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.

4. Shared report contracts

workers/pdf-worker/src/report-types.ts defines shared interfaces:

  • PDFGenerationData
  • PDFGenerationRequest
  • AuditTrailReportPayload
  • ReportRenderer
  • ReportModule

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.ts
  • workers/pdf-worker/src/report-types.ts
  • workers/pdf-worker/src/assets/generated-assets.ts

Embedded Report Assets

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.

Asset source directory

Place image files in:

workers/pdf-worker/src/assets/

Supported formats: .png, .jpg, .jpeg, .svg, .gif, .webp, .ico.

Generated module

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

Generation script

Run from workers/pdf-worker/:

npm run generate:assets

Script 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.

Using an asset in a report module

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;" />
  `;
};

Adding a new icon or logo

  1. Copy the image file into workers/pdf-worker/src/assets/.
  2. Run npm run generate:assets to regenerate src/assets/generated-assets.ts.
  3. Import the new constant from ../assets/generated-assets in the relevant report module.
  4. Deploy the worker: npm run deploy.

Note: src/assets/generated-assets.ts is committed to source control alongside the source assets in src/assets/. Both must stay in sync — always regenerate after adding or replacing an asset.

Request Contract

Preferred request shape

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.

Audit trail PDF request shape (case viewer export)

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": []
  }
}

Backward compatibility

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.

Format Resolution Flow

  1. Parse JSON body.
  2. Normalize reportFormat (trim, lowercase).
  3. If no valid format provided, use striae.
  4. Resolve data from envelope or legacy payload.
  5. Load module from reportModuleLoaders.
  6. Call module renderReport(data).
  7. If data.reportMode = audit-trail, the worker dispatcher routes rendering to the audit trail report renderer before format-module selection.
  8. Generate PDF from returned HTML.

Report Rendering Features

The Striae report module (format-striae.ts) implements several layout and content behaviors that report authors extending to new formats should be aware of.

Additional Notes

annotationData.additionalNotes is rendered in a dedicated section with:

  • white-space: pre-wrap and overflow-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-page CSS 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 Details

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.

Confirmation Block and Badge/ID

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

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.

PDF Page Header and Footer

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)

Add a New Agency Report Format

Step 1: Create a report module

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>`;
};

Step 2: Register it in loader map

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'),
};

Step 3: Select it at request time

From the frontend or API caller:

const pdfRequest = {
  reportFormat: 'agency-abc',
  data: pdfData,
};

Step 4: Keep the example worker loader in sync

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.ts and pdf-worker.example.ts)

Suggested Selection Strategies

Current production strategy:

  • Server-side email allowlist in the lists-worker (STRIAE_LISTS KV, key "primershear") sets primershear for matching verified users; all others receive striae.

Alternative strategies (when extending beyond the current resolver):

Strategy A: Static by company

Map known userCompany values to format names in the app before sending the request.

Strategy B: User profile setting

Store preferred report format in user profile data and send that as reportFormat.

Strategy C: Case-level override

Store report format on the case metadata and use it when generating the report.

Operational Notes

  • Keep reportFormat names 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-assets constants instead.
  • After adding or replacing any file in src/assets/, run npm run generate:assets and commit both the asset file and the regenerated src/assets/generated-assets.ts before deploying.

Troubleshooting

Error: Unsupported report format

Cause: format name not present in reportModuleLoaders.

Fix: register the module and deploy updated worker.

Error: Request body must be a JSON object

Cause: invalid JSON payload or non-object body.

Fix: ensure Content-Type: application/json and valid object payload.

PDF renders blank content

Cause: module returns invalid/empty HTML.

Fix: inspect module renderReport output and test with minimal HTML first.

Image or logo does not appear in PDF

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.

Related Files

  • functions/api/pdf/[[path]].ts
  • workers/pdf-worker/src/pdf-worker.ts
  • workers/pdf-worker/src/formats/format-striae.ts
  • workers/pdf-worker/src/formats/format-primer-shear.ts
  • workers/pdf-worker/src/report-types.ts
  • workers/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

Related Documentation

⚠️ **GitHub.com Fallback** ⚠️