@striae-org/striae 4.2.1 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/app/components/navbar/case-modals/archive-case-modal.module.css +0 -76
  2. package/app/components/navbar/case-modals/archive-case-modal.tsx +9 -8
  3. package/app/components/navbar/case-modals/case-modal-shared.module.css +94 -0
  4. package/app/components/navbar/case-modals/delete-case-modal.module.css +9 -0
  5. package/app/components/navbar/case-modals/delete-case-modal.tsx +79 -0
  6. package/app/components/navbar/case-modals/open-case-modal.module.css +2 -1
  7. package/app/components/navbar/case-modals/rename-case-modal.module.css +0 -72
  8. package/app/components/navbar/case-modals/rename-case-modal.tsx +9 -8
  9. package/app/components/sidebar/cases/case-sidebar.tsx +49 -3
  10. package/app/components/sidebar/cases/cases-modal.module.css +312 -10
  11. package/app/components/sidebar/cases/cases-modal.tsx +690 -110
  12. package/app/components/sidebar/cases/cases.module.css +23 -0
  13. package/app/components/sidebar/files/delete-files-modal.module.css +26 -0
  14. package/app/components/sidebar/files/delete-files-modal.tsx +94 -0
  15. package/app/components/sidebar/files/files-modal.module.css +285 -44
  16. package/app/components/sidebar/files/files-modal.tsx +452 -145
  17. package/app/components/sidebar/notes/class-details-fields.tsx +146 -0
  18. package/app/components/sidebar/notes/class-details-modal.tsx +147 -0
  19. package/app/components/sidebar/notes/class-details-sections.tsx +561 -0
  20. package/app/components/sidebar/notes/class-details-shared.ts +239 -0
  21. package/app/components/sidebar/notes/notes-editor-form.tsx +43 -5
  22. package/app/components/sidebar/notes/notes.module.css +236 -4
  23. package/app/components/sidebar/notes/use-class-details-state.ts +371 -0
  24. package/app/components/sidebar/sidebar-container.tsx +1 -0
  25. package/app/components/sidebar/sidebar.tsx +12 -1
  26. package/app/hooks/useCaseListPreferences.ts +99 -0
  27. package/app/hooks/useFileListPreferences.ts +106 -0
  28. package/app/routes/striae/striae.tsx +1 -0
  29. package/app/types/annotations.ts +48 -1
  30. package/app/utils/data/case-filters.ts +127 -0
  31. package/app/utils/data/confirmation-summary/summary-core.ts +18 -2
  32. package/app/utils/data/file-filters.ts +201 -0
  33. package/functions/api/image/[[path]].ts +4 -0
  34. package/package.json +3 -4
  35. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  36. package/workers/data-worker/wrangler.jsonc.example +1 -1
  37. package/workers/image-worker/wrangler.jsonc.example +1 -1
  38. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  39. package/workers/pdf-worker/src/formats/format-striae.ts +84 -118
  40. package/workers/pdf-worker/src/pdf-worker.example.ts +28 -10
  41. package/workers/pdf-worker/src/report-layout.ts +227 -0
  42. package/workers/pdf-worker/src/report-types.ts +20 -0
  43. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  44. package/workers/user-worker/wrangler.jsonc.example +1 -1
  45. package/wrangler.toml.example +1 -1
  46. package/workers/pdf-worker/src/assets/icon-256.png +0 -0
  47. /package/workers/pdf-worker/src/assets/{generated-assets.ts → generated-assets.example.ts} +0 -0
@@ -1,9 +1,12 @@
1
- import type { PDFGenerationData, ReportRenderer } from '../report-types';
1
+ import type { PDFGenerationData, ReportPdfOptionsBuilder, ReportRenderer } from '../report-types';
2
2
  import { ICON_256 } from '../assets/generated-assets';
3
+ import { buildRepeatedChromePdfOptions, escapeHtml } from '../report-layout';
3
4
 
4
5
  export const renderReport: ReportRenderer = (data: PDFGenerationData): string => {
5
- const { imageUrl, caseNumber, annotationData, activeAnnotations, currentDate, notesUpdatedFormatted, userCompany } = data;
6
+ const { imageUrl, annotationData, activeAnnotations } = data;
6
7
  const annotationsSet = new Set(activeAnnotations);
8
+ const hasImage = Boolean(imageUrl && imageUrl !== '/clear.jpg');
9
+ const safeText = (value: unknown): string => escapeHtml(String(value ?? ''));
7
10
 
8
11
  // Programmatically determine if a color is dark and needs a light background
9
12
  const needsLightBackground = (color: string | undefined): boolean => {
@@ -71,42 +74,23 @@ export const renderReport: ReportRenderer = (data: PDFGenerationData): string =>
71
74
  <style>
72
75
  html, body {
73
76
  width: 100%;
74
- height: 100%;
75
77
  margin: 0;
76
78
  font-family: Arial, sans-serif;
77
79
  background-color: white;
78
- display: flex;
79
- flex-direction: column;
80
- min-height: 100vh;
81
80
  }
82
- .header {
83
- display: flex;
84
- align-items: center;
85
- margin-bottom: 15px;
86
- border-bottom: 2px solid #333;
87
- padding-bottom: 8px;
88
- position: relative;
89
- }
90
- .header-content {
91
- flex: 1;
92
- display: flex;
93
- align-items: center;
94
- justify-content: space-between;
81
+ body {
82
+ color: #333;
95
83
  }
96
- .date {
97
- font-size: 16px;
98
- font-weight: bold;
84
+ .report-body {
85
+ width: 100%;
86
+ box-sizing: border-box;
99
87
  }
100
- .case-number {
101
- font-size: 16px;
102
- font-weight: bold;
103
- color: #333;
104
- text-align: right;
105
- }
106
88
  .image-container {
107
89
  width: 100%;
108
- margin: 10px 0;
90
+ margin: 0 0 10px;
109
91
  position: relative;
92
+ page-break-inside: avoid;
93
+ break-inside: avoid;
110
94
  }
111
95
  .image-wrapper {
112
96
  display: flex;
@@ -170,6 +154,8 @@ export const renderReport: ReportRenderer = (data: PDFGenerationData): string =>
170
154
  width: 100%;
171
155
  margin-top: 12px;
172
156
  min-height: 50px;
157
+ page-break-inside: avoid;
158
+ break-inside: avoid;
173
159
  }
174
160
  .support-level-annotation {
175
161
  flex: 1;
@@ -235,10 +221,21 @@ export const renderReport: ReportRenderer = (data: PDFGenerationData): string =>
235
221
  }
236
222
  .confirmation-section {
237
223
  margin-top: 20px;
224
+ display: flex;
225
+ flex-direction: column;
226
+ gap: 16px;
227
+ }
228
+ .notes-page {
229
+ page-break-before: always;
230
+ break-before: page;
231
+ }
232
+ .confirmation-summary {
238
233
  display: flex;
239
234
  justify-content: flex-start;
240
235
  align-items: flex-start;
241
236
  gap: 20px;
237
+ page-break-inside: avoid;
238
+ break-inside: avoid;
242
239
  }
243
240
  .confirmation-box {
244
241
  background: #ffffff;
@@ -247,6 +244,7 @@ export const renderReport: ReportRenderer = (data: PDFGenerationData): string =>
247
244
  padding: 15px;
248
245
  width: 280px;
249
246
  font-family: 'Inter', Arial, sans-serif;
247
+ box-sizing: border-box;
250
248
  }
251
249
  .confirmation-label {
252
250
  font-size: 14px;
@@ -274,6 +272,7 @@ export const renderReport: ReportRenderer = (data: PDFGenerationData): string =>
274
272
  padding: 15px;
275
273
  width: 280px;
276
274
  font-family: 'Inter', Arial, sans-serif;
275
+ box-sizing: border-box;
277
276
  }
278
277
  .confirmation-title {
279
278
  font-size: 14px;
@@ -316,64 +315,32 @@ export const renderReport: ReportRenderer = (data: PDFGenerationData): string =>
316
315
  letter-spacing: 1px;
317
316
  }
318
317
  .additional-notes-section {
319
- max-width: 400px;
318
+ border: 1px solid #d7dbe0;
319
+ border-radius: 8px;
320
+ background: #fafbfc;
321
+ padding: 16px 18px;
322
+ box-sizing: border-box;
320
323
  font-family: 'Inter', Arial, sans-serif;
321
- font-size: 14px;
322
- line-height: 1.6;
323
324
  color: #333;
324
- white-space: pre-wrap;
325
- word-wrap: break-word;
326
- text-indent: 0 !important;
327
- padding: 0;
328
- margin: 0;
329
- margin-left: 20px;
330
- flex-shrink: 0;
331
- text-align: left;
332
- display: block;
333
325
  }
334
- .footer {
335
- margin-top: auto;
336
- padding-top: 15px;
337
- border-top: 1px solid #ccc;
338
- display: flex;
339
- justify-content: space-between;
340
- align-items: center;
341
- font-family: 'Inter', Arial, sans-serif;
342
- font-size: 11px;
326
+ .additional-notes-title {
327
+ margin: 0 0 10px;
328
+ font-size: 12px;
329
+ font-weight: 700;
330
+ letter-spacing: 0.08em;
331
+ text-transform: uppercase;
343
332
  color: #666;
344
333
  }
345
- .main-content {
346
- flex: 1;
347
- display: flex;
348
- flex-direction: column;
349
- }
350
- .content-wrapper {
351
- flex-grow: 0;
352
- flex-shrink: 0;
353
- }
354
- .footer-left {
355
- font-weight: 500;
356
- flex: 1;
357
- text-align: left;
358
- display: flex;
359
- align-items: center;
360
- gap: 6px;
361
- }
362
- .footer-brand-icon {
363
- width: 14px;
364
- height: 14px;
365
- object-fit: contain;
366
- }
367
- .footer-center {
368
- font-weight: 600;
369
- flex: 1;
370
- text-align: center;
371
- color: #333;
372
- }
373
- .footer-right {
374
- font-style: italic;
375
- flex: 1;
376
- text-align: right;
334
+ .additional-notes-body {
335
+ margin: 0;
336
+ font-size: 14px;
337
+ line-height: 1.6;
338
+ white-space: pre-wrap;
339
+ overflow-wrap: anywhere;
340
+ word-break: break-word;
341
+ text-indent: 0 !important;
342
+ orphans: 3;
343
+ widows: 3;
377
344
  }
378
345
  .index-section {
379
346
  text-align: center;
@@ -395,19 +362,12 @@ export const renderReport: ReportRenderer = (data: PDFGenerationData): string =>
395
362
  </style>
396
363
  </head>
397
364
  <body>
398
- <div class="main-content">
399
- <div class="content-wrapper">
400
- <div class="header">
401
- <div class="header-content">
402
- <div class="date">${currentDate}</div>
403
- ${caseNumber ? `<div class="case-number">${caseNumber}</div>` : '<div class="case-number"></div>'}
404
- </div>
405
- </div>
365
+ <div class="report-body">
406
366
 
407
- ${imageUrl && imageUrl !== '/clear.jpg' ? `
367
+ ${hasImage ? `
408
368
  ${annotationData && annotationsSet?.has('index') && annotationData.indexType === 'number' && annotationData.indexNumber ? `
409
369
  <div class="index-section">
410
- Index: ${annotationData.indexNumber}
370
+ Index: ${safeText(annotationData.indexNumber)}
411
371
  </div>
412
372
  ` : ''}
413
373
 
@@ -419,12 +379,12 @@ export const renderReport: ReportRenderer = (data: PDFGenerationData): string =>
419
379
  <div class="annotations-overlay">
420
380
  <div class="left-annotation" style="${needsLightBackground(annotationData.caseFontColor || '#FFDE21') ? 'background: rgba(255, 255, 255, 0.9); border: 2px solid rgba(0, 0, 0, 0.2); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);' : ''}">
421
381
  <div class="case-text" style="color: ${annotationData.caseFontColor || '#FFDE21'};">
422
- ${annotationData.leftCase}${annotationData.leftItem ? ` ${annotationData.leftItem}` : ''}
382
+ ${safeText(annotationData.leftCase)}${annotationData.leftItem ? ` ${safeText(annotationData.leftItem)}` : ''}
423
383
  </div>
424
384
  </div>
425
385
  <div class="right-annotation" style="${needsLightBackground(annotationData.caseFontColor || '#FFDE21') ? 'background: rgba(255, 255, 255, 0.9); border: 2px solid rgba(0, 0, 0, 0.2); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);' : ''}">
426
386
  <div class="case-text" style="color: ${annotationData.caseFontColor || '#FFDE21'};">
427
- ${annotationData.rightCase}${annotationData.rightItem ? ` ${annotationData.rightItem}` : ''}
387
+ ${safeText(annotationData.rightCase)}${annotationData.rightItem ? ` ${safeText(annotationData.rightItem)}` : ''}
428
388
  </div>
429
389
  </div>
430
390
  </div>
@@ -450,7 +410,7 @@ export const renderReport: ReportRenderer = (data: PDFGenerationData): string =>
450
410
  ${annotationData && annotationsSet?.has('id') ? `
451
411
  <div class="support-level-annotation">
452
412
  <div class="support-level-text" style="color: ${annotationData.supportLevel === 'ID' ? '#28a745' : annotationData.supportLevel === 'Exclusion' ? '#dc3545' : '#ffc107'}; background: ${annotationData.supportLevel === 'Inconclusive' ? 'rgba(120, 120, 120, 0.95)' : 'rgba(240, 240, 240, 0.95)'};">
453
- ${annotationData.supportLevel === 'ID' ? 'Identification' : annotationData.supportLevel}
413
+ ${safeText(annotationData.supportLevel === 'ID' ? 'Identification' : annotationData.supportLevel)}
454
414
  </div>
455
415
  </div>
456
416
  ` : '<div class="support-level-annotation"></div>'}
@@ -458,7 +418,7 @@ export const renderReport: ReportRenderer = (data: PDFGenerationData): string =>
458
418
  ${annotationData && annotationsSet?.has('class') ? `
459
419
  <div class="class-annotation">
460
420
  <div class="class-text-annotation">
461
- ${annotationData.customClass || annotationData.classType}${annotationData.classNote ? ` (${annotationData.classNote})` : ''}
421
+ ${safeText(annotationData.customClass || annotationData.classType)}${annotationData.classNote ? ` (${safeText(annotationData.classNote)})` : ''}
462
422
  </div>
463
423
  </div>
464
424
  ` : '<div class="class-annotation"></div>'}
@@ -477,20 +437,21 @@ export const renderReport: ReportRenderer = (data: PDFGenerationData): string =>
477
437
  ${annotationData && ((annotationData.includeConfirmation === true) || annotationData.additionalNotes) ? `
478
438
  <div class="confirmation-section">
479
439
  ${annotationData && (annotationData.includeConfirmation === true) ? `
440
+ <div class="confirmation-summary">
480
441
  ${annotationData.confirmationData ? `
481
442
  <div class="confirmation-data">
482
443
  <div class="confirmation-title">IDENTIFICATION CONFIRMED</div>
483
444
  <div class="confirmation-field">
484
- <div class="confirmation-name">${annotationData.confirmationData.fullName}, ${annotationData.confirmationData.badgeId}</div>
445
+ <div class="confirmation-name">${safeText(annotationData.confirmationData.fullName)}, ${safeText(annotationData.confirmationData.badgeId)}</div>
485
446
  </div>
486
447
  <div class="confirmation-field">
487
- <div class="confirmation-company">${annotationData.confirmationData.confirmedByCompany || 'N/A'}</div>
448
+ <div class="confirmation-company">${safeText(annotationData.confirmationData.confirmedByCompany || 'N/A')}</div>
488
449
  </div>
489
450
  <div class="confirmation-field">
490
- <div class="confirmation-timestamp">${annotationData.confirmationData.timestamp}</div>
451
+ <div class="confirmation-timestamp">${safeText(annotationData.confirmationData.timestamp)}</div>
491
452
  </div>
492
453
  <div class="confirmation-field">
493
- <div class="confirmation-id">ID: ${annotationData.confirmationData.confirmationId}</div>
454
+ <div class="confirmation-id">ID: ${safeText(annotationData.confirmationData.confirmationId)}</div>
494
455
  </div>
495
456
  </div>
496
457
  ` : `
@@ -501,30 +462,35 @@ export const renderReport: ReportRenderer = (data: PDFGenerationData): string =>
501
462
  <div class="confirmation-line"></div>
502
463
  </div>
503
464
  `}
504
- ` : '<div></div>'}
465
+ </div>
466
+ ` : ''}
505
467
 
506
468
  ${annotationData && annotationsSet?.has('notes') && annotationData.additionalNotes && annotationData.additionalNotes.trim() ? `
507
- <div class="additional-notes-section">${annotationData.additionalNotes.trim()}</div>
508
- ` : '<div></div>'}
469
+ <section class="additional-notes-section ${hasImage || annotationData.includeConfirmation === true ? 'notes-page' : ''}">
470
+ <h2 class="additional-notes-title">Additional Notes</h2>
471
+ <p class="additional-notes-body">${escapeHtml(annotationData.additionalNotes.trim())}</p>
472
+ </section>
473
+ ` : ''}
509
474
  </div>
510
475
  ` : ''}
511
476
 
512
477
  </div>
513
- </div>
514
-
515
- <div class="footer">
516
- <div class="footer-left">
517
- <span>Notes formatted by Striae</span>
518
- <img class="footer-brand-icon" src="${ICON_256}" alt="Striae icon" />
519
- </div>
520
- <div class="footer-center">
521
- ${userCompany ? userCompany : ''}
522
- </div>
523
- <div class="footer-right">
524
- ${notesUpdatedFormatted ? `Notes updated ${notesUpdatedFormatted}` : ''}
525
- </div>
526
- </div>
527
478
  </body>
528
479
  </html>
529
480
  `;
530
- };
481
+ };
482
+
483
+ export const getPdfOptions: ReportPdfOptionsBuilder = (data: PDFGenerationData) => buildRepeatedChromePdfOptions({
484
+ headerLeft: data.currentDate,
485
+ headerRight: data.caseNumber,
486
+ headerDetailLeft: [data.annotationData?.leftCase, data.annotationData?.leftItem].filter(Boolean).join(' / ')
487
+ ? `Left Case / Item: ${[data.annotationData?.leftCase, data.annotationData?.leftItem].filter(Boolean).join(' / ')}`
488
+ : undefined,
489
+ headerDetailRight: [data.annotationData?.rightCase, data.annotationData?.rightItem].filter(Boolean).join(' / ')
490
+ ? `Right Case / Item: ${[data.annotationData?.rightCase, data.annotationData?.rightItem].filter(Boolean).join(' / ')}`
491
+ : undefined,
492
+ footerLeft: 'Notes formatted by Striae',
493
+ footerCenter: data.userCompany,
494
+ footerRight: data.notesUpdatedFormatted ? `Notes updated ${data.notesUpdatedFormatted}` : undefined,
495
+ footerLeftImageSrc: ICON_256,
496
+ });
@@ -1,16 +1,16 @@
1
- import type { PDFGenerationData, PDFGenerationRequest, ReportModule } from './report-types';
1
+ import type { PDFGenerationData, PDFGenerationRequest, ReportModule, ReportPdfOptions } from './report-types';
2
2
 
3
3
  interface Env {
4
4
  BROWSER: Fetcher;
5
5
  PDF_WORKER_AUTH: string;
6
- ACCOUNT_ID: string;
7
- BROWSER_API_TOKEN: string;
6
+ ACCOUNT_ID?: string;
7
+ BROWSER_API_TOKEN?: string;
8
8
  }
9
9
 
10
10
  const BROWSER_PDF_TIMEOUT_MS = 90_000;
11
11
  const BROWSER_RENDERING_API_BASE = 'https://api.cloudflare.com/client/v4/accounts';
12
12
 
13
- const DEFAULT_PDF_OPTIONS = {
13
+ const DEFAULT_PDF_OPTIONS: ReportPdfOptions = {
14
14
  printBackground: true,
15
15
  format: 'letter',
16
16
  margin: {
@@ -21,6 +21,17 @@ const DEFAULT_PDF_OPTIONS = {
21
21
  },
22
22
  };
23
23
 
24
+ function resolvePdfOptions(overrides?: Partial<ReportPdfOptions>): ReportPdfOptions {
25
+ return {
26
+ ...DEFAULT_PDF_OPTIONS,
27
+ ...overrides,
28
+ margin: {
29
+ ...DEFAULT_PDF_OPTIONS.margin,
30
+ ...overrides?.margin,
31
+ },
32
+ };
33
+ }
34
+
24
35
  const reportModuleLoaders: Record<string, () => Promise<ReportModule>> = {
25
36
  // Default Striae report format module
26
37
  striae: () => import('./formats/format-striae'),
@@ -63,7 +74,11 @@ class MissingSecretError extends Error {
63
74
  }
64
75
  }
65
76
 
66
- function getRequiredSecret(value: string, name: string): string {
77
+ function getRequiredSecret(value: string | undefined, name: string): string {
78
+ if (typeof value !== 'string') {
79
+ throw new MissingSecretError(name);
80
+ }
81
+
67
82
  const normalized = value.trim();
68
83
  if (!normalized) {
69
84
  throw new MissingSecretError(name);
@@ -97,7 +112,7 @@ function resolveReportRequest(payload: unknown): PDFGenerationRequest {
97
112
  };
98
113
  }
99
114
 
100
- async function renderReport(reportFormat: string, data: PDFGenerationData): Promise<string> {
115
+ async function renderReport(reportFormat: string, data: PDFGenerationData): Promise<{ html: string; pdfOptions: ReportPdfOptions }> {
101
116
  const loader = reportModuleLoaders[reportFormat];
102
117
 
103
118
  if (!loader) {
@@ -106,17 +121,20 @@ async function renderReport(reportFormat: string, data: PDFGenerationData): Prom
106
121
  }
107
122
 
108
123
  const reportModule = await loader();
109
- return reportModule.renderReport(data);
124
+ return {
125
+ html: reportModule.renderReport(data),
126
+ pdfOptions: resolvePdfOptions(reportModule.getPdfOptions?.(data)),
127
+ };
110
128
  }
111
129
 
112
- async function renderPdfViaRestEndpoint(env: Env, html: string): Promise<Response> {
130
+ async function renderPdfViaRestEndpoint(env: Env, html: string, pdfOptions: ReportPdfOptions): Promise<Response> {
113
131
  const accountId = getRequiredSecret(env.ACCOUNT_ID, 'ACCOUNT_ID');
114
132
  const browserApiToken = getRequiredSecret(env.BROWSER_API_TOKEN, 'BROWSER_API_TOKEN');
115
133
 
116
134
  const endpoint = `${BROWSER_RENDERING_API_BASE}/${accountId}/browser-rendering/pdf`;
117
135
  const requestBody = JSON.stringify({
118
136
  html,
119
- pdfOptions: DEFAULT_PDF_OPTIONS,
137
+ pdfOptions,
120
138
  });
121
139
 
122
140
  let endpointResponse: Response;
@@ -192,7 +210,7 @@ export default {
192
210
  const { reportFormat, data } = resolveReportRequest(payload);
193
211
  const document = await renderReport(reportFormat, data);
194
212
 
195
- return await renderPdfViaRestEndpoint(env, document);
213
+ return await renderPdfViaRestEndpoint(env, document.html, document.pdfOptions);
196
214
  } catch (error) {
197
215
  if (error instanceof MissingSecretError) {
198
216
  console.error(`[pdf-worker] Configuration error: ${error.message}`);
@@ -0,0 +1,227 @@
1
+ import type { ReportPdfOptions } from './report-types';
2
+
3
+ interface ReportChromeTemplateConfig {
4
+ headerLeft?: string;
5
+ headerCenter?: string;
6
+ headerRight?: string;
7
+ headerDetailLeft?: string;
8
+ headerDetailRight?: string;
9
+ footerLeft?: string;
10
+ footerCenter?: string;
11
+ footerRight?: string;
12
+ footerLeftImageSrc?: string;
13
+ includePageNumbers?: boolean;
14
+ }
15
+
16
+ const HEADER_TEMPLATE_STYLES = `
17
+ <style>
18
+ .report-header {
19
+ width: 100%;
20
+ box-sizing: border-box;
21
+ padding: 0 0.5in 8px;
22
+ border-bottom: 2px solid #333333;
23
+ color: #333333;
24
+ font-family: Arial, sans-serif;
25
+ font-size: 18px;
26
+ font-weight: 700;
27
+ }
28
+ .report-header__content {
29
+ display: flex;
30
+ align-items: center;
31
+ justify-content: space-between;
32
+ gap: 12px;
33
+ width: 100%;
34
+ }
35
+ .report-header__details {
36
+ display: flex;
37
+ align-items: flex-start;
38
+ justify-content: space-between;
39
+ gap: 12px;
40
+ width: 100%;
41
+ margin-top: 8px;
42
+ padding-top: 8px;
43
+ border-top: 1px solid #d9d9d9;
44
+ font-size: 10px;
45
+ font-weight: 600;
46
+ letter-spacing: 0.04em;
47
+ text-transform: uppercase;
48
+ color: #666666;
49
+ }
50
+ .report-header__cell {
51
+ flex: 1 1 0;
52
+ min-width: 0;
53
+ white-space: nowrap;
54
+ overflow: hidden;
55
+ text-overflow: ellipsis;
56
+ }
57
+ .report-header__cell--left {
58
+ text-align: left;
59
+ }
60
+ .report-header__cell--center {
61
+ text-align: center;
62
+ }
63
+ .report-header__cell--right {
64
+ text-align: right;
65
+ }
66
+ .report-header__detail {
67
+ flex: 1 1 0;
68
+ min-width: 0;
69
+ white-space: nowrap;
70
+ overflow: hidden;
71
+ text-overflow: ellipsis;
72
+ }
73
+ .report-header__detail--left {
74
+ text-align: left;
75
+ }
76
+ .report-header__detail--right {
77
+ text-align: right;
78
+ }
79
+ </style>
80
+ `;
81
+
82
+ const FOOTER_TEMPLATE_STYLES = `
83
+ <style>
84
+ .report-footer {
85
+ width: 100%;
86
+ box-sizing: border-box;
87
+ padding: 8px 0.5in 0;
88
+ border-top: 1px solid #cccccc;
89
+ color: #666666;
90
+ font-family: Arial, sans-serif;
91
+ font-size: 9px;
92
+ }
93
+ .report-footer__content {
94
+ display: flex;
95
+ align-items: center;
96
+ justify-content: space-between;
97
+ gap: 12px;
98
+ width: 100%;
99
+ }
100
+ .report-footer__cell {
101
+ flex: 1 1 0;
102
+ min-width: 0;
103
+ white-space: nowrap;
104
+ overflow: hidden;
105
+ text-overflow: ellipsis;
106
+ }
107
+ .report-footer__cell--left {
108
+ display: flex;
109
+ align-items: center;
110
+ gap: 6px;
111
+ text-align: left;
112
+ font-weight: 500;
113
+ }
114
+ .report-footer__cell--center {
115
+ text-align: center;
116
+ color: #333333;
117
+ font-weight: 600;
118
+ }
119
+ .report-footer__cell--right {
120
+ text-align: right;
121
+ font-style: italic;
122
+ }
123
+ .report-footer__page-count {
124
+ font-style: normal;
125
+ font-weight: 600;
126
+ color: #333333;
127
+ }
128
+ .report-footer__separator {
129
+ margin: 0 6px;
130
+ color: #999999;
131
+ font-style: normal;
132
+ }
133
+ .report-footer__icon {
134
+ width: 12px;
135
+ height: 12px;
136
+ object-fit: contain;
137
+ flex: 0 0 auto;
138
+ }
139
+ </style>
140
+ `;
141
+
142
+ export function escapeHtml(value: string | undefined): string {
143
+ if (!value) {
144
+ return '';
145
+ }
146
+
147
+ return value
148
+ .replace(/&/g, '&amp;')
149
+ .replace(/</g, '&lt;')
150
+ .replace(/>/g, '&gt;')
151
+ .replace(/"/g, '&quot;')
152
+ .replace(/'/g, '&#39;');
153
+ }
154
+
155
+ function renderTemplateCell(value: string | undefined, className: string): string {
156
+ const content = value && value.trim().length > 0 ? escapeHtml(value.trim()) : '&nbsp;';
157
+ return `<div class="${className}">${content}</div>`;
158
+ }
159
+
160
+ export function buildRepeatedChromePdfOptions(config: ReportChromeTemplateConfig): Partial<ReportPdfOptions> {
161
+ const hasHeaderDetails = Boolean(
162
+ (config.headerDetailLeft && config.headerDetailLeft.trim().length > 0) ||
163
+ (config.headerDetailRight && config.headerDetailRight.trim().length > 0)
164
+ );
165
+
166
+ const headerDetails = hasHeaderDetails
167
+ ? `
168
+ <div class="report-header__details">
169
+ ${renderTemplateCell(config.headerDetailLeft, 'report-header__detail report-header__detail--left')}
170
+ ${renderTemplateCell(config.headerDetailRight, 'report-header__detail report-header__detail--right')}
171
+ </div>
172
+ `
173
+ : '';
174
+
175
+ const headerTemplate = `
176
+ ${HEADER_TEMPLATE_STYLES}
177
+ <div class="report-header">
178
+ <div class="report-header__content">
179
+ ${renderTemplateCell(config.headerLeft, 'report-header__cell report-header__cell--left')}
180
+ ${renderTemplateCell(config.headerCenter, 'report-header__cell report-header__cell--center')}
181
+ ${renderTemplateCell(config.headerRight, 'report-header__cell report-header__cell--right')}
182
+ </div>
183
+ ${headerDetails}
184
+ </div>
185
+ `;
186
+
187
+ const footerLeftContent = config.footerLeft && config.footerLeft.trim().length > 0
188
+ ? `<span>${escapeHtml(config.footerLeft.trim())}</span>`
189
+ : '<span>&nbsp;</span>';
190
+
191
+ const footerIcon = config.footerLeftImageSrc
192
+ ? `<img class="report-footer__icon" src="${escapeHtml(config.footerLeftImageSrc)}" alt="" />`
193
+ : '';
194
+
195
+ const footerRightText = config.footerRight && config.footerRight.trim().length > 0
196
+ ? `<span>${escapeHtml(config.footerRight.trim())}</span>`
197
+ : '';
198
+
199
+ const footerPageCount = config.includePageNumbers === false
200
+ ? ''
201
+ : `<span class="report-footer__page-count">Page <span class="pageNumber"></span> of <span class="totalPages"></span></span>`;
202
+
203
+ const footerRightContent = footerRightText && footerPageCount
204
+ ? `${footerRightText}<span class="report-footer__separator">|</span>${footerPageCount}`
205
+ : footerRightText || footerPageCount || '&nbsp;';
206
+
207
+ const footerTemplate = `
208
+ ${FOOTER_TEMPLATE_STYLES}
209
+ <div class="report-footer">
210
+ <div class="report-footer__content">
211
+ <div class="report-footer__cell report-footer__cell--left">${footerLeftContent}${footerIcon}</div>
212
+ ${renderTemplateCell(config.footerCenter, 'report-footer__cell report-footer__cell--center')}
213
+ <div class="report-footer__cell report-footer__cell--right">${footerRightContent}</div>
214
+ </div>
215
+ </div>
216
+ `;
217
+
218
+ return {
219
+ displayHeaderFooter: true,
220
+ headerTemplate,
221
+ footerTemplate,
222
+ margin: {
223
+ top: hasHeaderDetails ? '1.45in' : '1.15in',
224
+ bottom: '0.8in',
225
+ },
226
+ };
227
+ }
@@ -65,8 +65,28 @@ export interface PDFGenerationRequest {
65
65
  data: PDFGenerationData;
66
66
  }
67
67
 
68
+ export interface PDFMarginOptions {
69
+ top: string;
70
+ bottom: string;
71
+ left: string;
72
+ right: string;
73
+ }
74
+
75
+ export interface ReportPdfOptions {
76
+ printBackground?: boolean;
77
+ format?: string;
78
+ margin?: Partial<PDFMarginOptions>;
79
+ displayHeaderFooter?: boolean;
80
+ headerTemplate?: string;
81
+ footerTemplate?: string;
82
+ preferCSSPageSize?: boolean;
83
+ }
84
+
68
85
  export type ReportRenderer = (data: PDFGenerationData) => string;
69
86
 
87
+ export type ReportPdfOptionsBuilder = (data: PDFGenerationData) => Partial<ReportPdfOptions>;
88
+
70
89
  export interface ReportModule {
71
90
  renderReport: ReportRenderer;
91
+ getPdfOptions?: ReportPdfOptionsBuilder;
72
92
  }
@@ -2,7 +2,7 @@
2
2
  "name": "PDF_WORKER_NAME",
3
3
  "account_id": "ACCOUNT_ID",
4
4
  "main": "src/pdf-worker.ts",
5
- "compatibility_date": "2026-03-21",
5
+ "compatibility_date": "2026-03-22",
6
6
  "compatibility_flags": [
7
7
  "nodejs_compat"
8
8
  ],