@hed-hog/core 0.0.400 → 0.0.401

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 (203) hide show
  1. package/dist/ai/ai.controller.d.ts +2 -0
  2. package/dist/ai/ai.controller.d.ts.map +1 -1
  3. package/dist/ai/ai.service.d.ts +86 -2
  4. package/dist/ai/ai.service.d.ts.map +1 -1
  5. package/dist/ai/ai.service.js +304 -23
  6. package/dist/ai/ai.service.js.map +1 -1
  7. package/dist/ai-instruction/ai-prompt-resolver.service.d.ts +12 -0
  8. package/dist/ai-instruction/ai-prompt-resolver.service.d.ts.map +1 -1
  9. package/dist/ai-instruction/ai-prompt-resolver.service.js +26 -0
  10. package/dist/ai-instruction/ai-prompt-resolver.service.js.map +1 -1
  11. package/dist/core.module.d.ts.map +1 -1
  12. package/dist/core.module.js +3 -0
  13. package/dist/core.module.js.map +1 -1
  14. package/dist/document-kit/document-kit.module.d.ts +3 -0
  15. package/dist/document-kit/document-kit.module.d.ts.map +1 -0
  16. package/dist/document-kit/document-kit.module.js +24 -0
  17. package/dist/document-kit/document-kit.module.js.map +1 -0
  18. package/dist/document-kit/document-kit.registry.d.ts +55 -0
  19. package/dist/document-kit/document-kit.registry.d.ts.map +1 -0
  20. package/dist/document-kit/document-kit.registry.js +91 -0
  21. package/dist/document-kit/document-kit.registry.js.map +1 -0
  22. package/dist/document-kit/document-kit.service.d.ts +49 -0
  23. package/dist/document-kit/document-kit.service.d.ts.map +1 -0
  24. package/dist/document-kit/document-kit.service.js +135 -0
  25. package/dist/document-kit/document-kit.service.js.map +1 -0
  26. package/dist/file/file.service.d.ts +15 -1
  27. package/dist/file/file.service.d.ts.map +1 -1
  28. package/dist/file/file.service.js +52 -24
  29. package/dist/file/file.service.js.map +1 -1
  30. package/dist/file/mcp-file-source.schema.d.ts.map +1 -1
  31. package/dist/file/mcp-file-source.schema.js +8 -5
  32. package/dist/file/mcp-file-source.schema.js.map +1 -1
  33. package/dist/index.d.ts +5 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +5 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
  38. package/dist/integration-profile/integration-profile.secrets.js +21 -0
  39. package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
  40. package/dist/integration-profile/testers/ai.testers.d.ts.map +1 -1
  41. package/dist/integration-profile/testers/ai.testers.js +46 -8
  42. package/dist/integration-profile/testers/ai.testers.js.map +1 -1
  43. package/dist/mcp/mcp.module.d.ts.map +1 -1
  44. package/dist/mcp/mcp.module.js +2 -0
  45. package/dist/mcp/mcp.module.js.map +1 -1
  46. package/dist/mcp/tools/core-access-log.mcp-tools.d.ts +17 -0
  47. package/dist/mcp/tools/core-access-log.mcp-tools.d.ts.map +1 -0
  48. package/dist/mcp/tools/core-access-log.mcp-tools.js +74 -0
  49. package/dist/mcp/tools/core-access-log.mcp-tools.js.map +1 -0
  50. package/dist/mcp/tools/core-files.mcp-tools.js +4 -4
  51. package/dist/mcp/tools/core-files.mcp-tools.js.map +1 -1
  52. package/dist/meeting/meeting-provider.registry.d.ts +72 -0
  53. package/dist/meeting/meeting-provider.registry.d.ts.map +1 -1
  54. package/dist/meeting/meeting-provider.registry.js.map +1 -1
  55. package/dist/oauth/oauth-token-broker.service.d.ts +50 -1
  56. package/dist/oauth/oauth-token-broker.service.d.ts.map +1 -1
  57. package/dist/oauth/oauth-token-broker.service.js +105 -3
  58. package/dist/oauth/oauth-token-broker.service.js.map +1 -1
  59. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts +6 -0
  60. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts.map +1 -1
  61. package/dist/oauth/providers/microsoft-entra-id.provider.js +30 -4
  62. package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
  63. package/dist/pdf/document/charts/chart-bar.d.ts +24 -0
  64. package/dist/pdf/document/charts/chart-bar.d.ts.map +1 -0
  65. package/dist/pdf/document/charts/chart-bar.js +117 -0
  66. package/dist/pdf/document/charts/chart-bar.js.map +1 -0
  67. package/dist/pdf/document/charts/chart-donut.d.ts +16 -0
  68. package/dist/pdf/document/charts/chart-donut.d.ts.map +1 -0
  69. package/dist/pdf/document/charts/chart-donut.js +89 -0
  70. package/dist/pdf/document/charts/chart-donut.js.map +1 -0
  71. package/dist/pdf/document/charts/chart-gauge.d.ts +15 -0
  72. package/dist/pdf/document/charts/chart-gauge.d.ts.map +1 -0
  73. package/dist/pdf/document/charts/chart-gauge.js +60 -0
  74. package/dist/pdf/document/charts/chart-gauge.js.map +1 -0
  75. package/dist/pdf/document/charts/chart-line.d.ts +29 -0
  76. package/dist/pdf/document/charts/chart-line.d.ts.map +1 -0
  77. package/dist/pdf/document/charts/chart-line.js +89 -0
  78. package/dist/pdf/document/charts/chart-line.js.map +1 -0
  79. package/dist/pdf/document/charts/chart-primitives.d.ts +64 -0
  80. package/dist/pdf/document/charts/chart-primitives.d.ts.map +1 -0
  81. package/dist/pdf/document/charts/chart-primitives.js +110 -0
  82. package/dist/pdf/document/charts/chart-primitives.js.map +1 -0
  83. package/dist/pdf/document/charts/chart-radar.d.ts +13 -0
  84. package/dist/pdf/document/charts/chart-radar.d.ts.map +1 -0
  85. package/dist/pdf/document/charts/chart-radar.js +83 -0
  86. package/dist/pdf/document/charts/chart-radar.js.map +1 -0
  87. package/dist/pdf/document/charts/chart-scale.d.ts +39 -0
  88. package/dist/pdf/document/charts/chart-scale.d.ts.map +1 -0
  89. package/dist/pdf/document/charts/chart-scale.js +118 -0
  90. package/dist/pdf/document/charts/chart-scale.js.map +1 -0
  91. package/dist/pdf/document/charts/index.d.ts +8 -0
  92. package/dist/pdf/document/charts/index.d.ts.map +1 -0
  93. package/dist/pdf/document/charts/index.js +24 -0
  94. package/dist/pdf/document/charts/index.js.map +1 -0
  95. package/dist/pdf/document/index.d.ts +8 -0
  96. package/dist/pdf/document/index.d.ts.map +1 -0
  97. package/dist/pdf/document/index.js +24 -0
  98. package/dist/pdf/document/index.js.map +1 -0
  99. package/dist/pdf/document/pdf-document.chrome.d.ts +20 -0
  100. package/dist/pdf/document/pdf-document.chrome.d.ts.map +1 -0
  101. package/dist/pdf/document/pdf-document.chrome.js +36 -0
  102. package/dist/pdf/document/pdf-document.chrome.js.map +1 -0
  103. package/dist/pdf/document/pdf-document.css.d.ts +20 -0
  104. package/dist/pdf/document/pdf-document.css.d.ts.map +1 -0
  105. package/dist/pdf/document/pdf-document.css.js +239 -0
  106. package/dist/pdf/document/pdf-document.css.js.map +1 -0
  107. package/dist/pdf/document/pdf-document.renderer.d.ts +10 -0
  108. package/dist/pdf/document/pdf-document.renderer.d.ts.map +1 -0
  109. package/dist/pdf/document/pdf-document.renderer.js +153 -0
  110. package/dist/pdf/document/pdf-document.renderer.js.map +1 -0
  111. package/dist/pdf/document/pdf-document.service.d.ts +27 -0
  112. package/dist/pdf/document/pdf-document.service.d.ts.map +1 -0
  113. package/dist/pdf/document/pdf-document.service.js +81 -0
  114. package/dist/pdf/document/pdf-document.service.js.map +1 -0
  115. package/dist/pdf/document/pdf-document.theme.d.ts +41 -0
  116. package/dist/pdf/document/pdf-document.theme.d.ts.map +1 -0
  117. package/dist/pdf/document/pdf-document.theme.js +105 -0
  118. package/dist/pdf/document/pdf-document.theme.js.map +1 -0
  119. package/dist/pdf/document/pdf-document.types.d.ts +108 -0
  120. package/dist/pdf/document/pdf-document.types.d.ts.map +1 -0
  121. package/dist/pdf/document/pdf-document.types.js +18 -0
  122. package/dist/pdf/document/pdf-document.types.js.map +1 -0
  123. package/dist/pdf/pdf.module.d.ts.map +1 -1
  124. package/dist/pdf/pdf.module.js +3 -2
  125. package/dist/pdf/pdf.module.js.map +1 -1
  126. package/dist/pdf/pdf.service.d.ts +69 -1
  127. package/dist/pdf/pdf.service.d.ts.map +1 -1
  128. package/dist/pdf/pdf.service.js +164 -63
  129. package/dist/pdf/pdf.service.js.map +1 -1
  130. package/dist/pdf/templates/commercial-proposal.template.d.ts +96 -0
  131. package/dist/pdf/templates/commercial-proposal.template.d.ts.map +1 -0
  132. package/dist/pdf/templates/commercial-proposal.template.js +276 -0
  133. package/dist/pdf/templates/commercial-proposal.template.js.map +1 -0
  134. package/dist/setting/favicon.controller.d.ts +26 -0
  135. package/dist/setting/favicon.controller.d.ts.map +1 -0
  136. package/dist/setting/favicon.controller.js +130 -0
  137. package/dist/setting/favicon.controller.js.map +1 -0
  138. package/dist/setting/setting.module.d.ts.map +1 -1
  139. package/dist/setting/setting.module.js +2 -1
  140. package/dist/setting/setting.module.js.map +1 -1
  141. package/dist/whatsapp-sender/whatsapp-sender.service.d.ts.map +1 -1
  142. package/dist/whatsapp-sender/whatsapp-sender.service.js +25 -2
  143. package/dist/whatsapp-sender/whatsapp-sender.service.js.map +1 -1
  144. package/hedhog/data/integration_type.yaml +6 -0
  145. package/hedhog/data/route.yaml +8 -0
  146. package/hedhog/data/setting_group.yaml +52 -0
  147. package/hedhog/frontend/messages/en.json +54 -1
  148. package/hedhog/frontend/messages/pt.json +54 -1
  149. package/package.json +4 -4
  150. package/src/ai/ai-attachment.fragments.spec.ts +56 -3
  151. package/src/ai/ai.service.responses.spec.ts +429 -0
  152. package/src/ai/ai.service.ts +409 -27
  153. package/src/ai-instruction/ai-prompt-resolver.service.spec.ts +44 -0
  154. package/src/ai-instruction/ai-prompt-resolver.service.ts +29 -0
  155. package/src/core.module.ts +3 -0
  156. package/src/document-kit/document-kit.module.ts +12 -0
  157. package/src/document-kit/document-kit.registry.ts +125 -0
  158. package/src/document-kit/document-kit.service.spec.ts +156 -0
  159. package/src/document-kit/document-kit.service.ts +173 -0
  160. package/src/file/file.service.spec.ts +168 -1
  161. package/src/file/file.service.ts +72 -34
  162. package/src/file/mcp-file-source.schema.ts +8 -5
  163. package/src/index.ts +5 -0
  164. package/src/integration-profile/integration-profile.secrets.ts +21 -0
  165. package/src/integration-profile/testers/ai.testers.ts +70 -5
  166. package/src/integration-profile/testers/remaining.testers.spec.ts +65 -1
  167. package/src/integration-profile/testers/tester-coverage.spec.ts +10 -0
  168. package/src/mcp/mcp.module.ts +2 -0
  169. package/src/mcp/tools/core-access-log.mcp-tools.ts +66 -0
  170. package/src/mcp/tools/core-files.mcp-tools.ts +4 -4
  171. package/src/meeting/meeting-provider.registry.ts +80 -0
  172. package/src/oauth/oauth-token-broker.service.spec.ts +108 -0
  173. package/src/oauth/oauth-token-broker.service.ts +129 -2
  174. package/src/oauth/providers/microsoft-entra-id.provider.spec.ts +133 -0
  175. package/src/oauth/providers/microsoft-entra-id.provider.ts +29 -2
  176. package/src/pdf/document/charts/chart-bar.ts +217 -0
  177. package/src/pdf/document/charts/chart-donut.ts +163 -0
  178. package/src/pdf/document/charts/chart-gauge.ts +94 -0
  179. package/src/pdf/document/charts/chart-line.ts +159 -0
  180. package/src/pdf/document/charts/chart-primitives.ts +188 -0
  181. package/src/pdf/document/charts/chart-radar.ts +136 -0
  182. package/src/pdf/document/charts/chart-scale.spec.ts +93 -0
  183. package/src/pdf/document/charts/chart-scale.ts +141 -0
  184. package/src/pdf/document/charts/charts.spec.ts +157 -0
  185. package/src/pdf/document/charts/index.ts +7 -0
  186. package/src/pdf/document/index.ts +7 -0
  187. package/src/pdf/document/pdf-document.chrome.ts +38 -0
  188. package/src/pdf/document/pdf-document.css.ts +238 -0
  189. package/src/pdf/document/pdf-document.renderer.spec.ts +129 -0
  190. package/src/pdf/document/pdf-document.renderer.ts +215 -0
  191. package/src/pdf/document/pdf-document.service.ts +77 -0
  192. package/src/pdf/document/pdf-document.theme.ts +146 -0
  193. package/src/pdf/document/pdf-document.types.ts +97 -0
  194. package/src/pdf/pdf.module.ts +3 -2
  195. package/src/pdf/pdf.service.spec.ts +131 -0
  196. package/src/pdf/pdf.service.ts +245 -82
  197. package/src/pdf/templates/commercial-proposal.template.spec.ts +177 -0
  198. package/src/pdf/templates/commercial-proposal.template.ts +400 -0
  199. package/src/setting/favicon.controller.spec.ts +92 -0
  200. package/src/setting/favicon.controller.ts +135 -0
  201. package/src/setting/setting.module.ts +2 -1
  202. package/src/whatsapp-sender/whatsapp-sender.service.spec.ts +73 -0
  203. package/src/whatsapp-sender/whatsapp-sender.service.ts +32 -2
@@ -1,9 +1,21 @@
1
- import { Inject, Injectable, InternalServerErrorException, Logger, forwardRef } from '@nestjs/common';
1
+ import {
2
+ Inject,
3
+ Injectable,
4
+ InternalServerErrorException,
5
+ Logger,
6
+ ServiceUnavailableException,
7
+ forwardRef,
8
+ } from '@nestjs/common';
2
9
  import { ConfigService } from '@nestjs/config';
3
10
 
4
11
  import { BrandAssetService } from '../setting/brand-asset.service';
5
12
  import { SettingService } from '../setting/setting.service';
6
13
  import { PdfAreaType, PdfThemeConfig } from './interfaces/pdf-theme-config.interface';
14
+ import {
15
+ CommercialDocumentTheme,
16
+ CommercialProposalData,
17
+ commercialProposalTemplate,
18
+ } from './templates/commercial-proposal.template';
7
19
  import { hcodeInstitutionalTemplate } from './templates/hcode-institutional.template';
8
20
  import { AreaDefaults, HCODE_DEFAULTS } from './themes/hcode.theme';
9
21
 
@@ -20,8 +32,40 @@ export interface PdfGenerateOptions {
20
32
  width?: string;
21
33
  /** Explicit CSS page size (e.g. '1131px'). Takes precedence over `format`/`landscape` when set. */
22
34
  height?: string;
35
+
36
+ /**
37
+ * Turns on Chromium's own per-page header and footer. This is the only way to
38
+ * get a repeating accent bar and a real page number: `counter(page)` resolves
39
+ * to nothing outside `@page` margin boxes, so a footer rendered as flow
40
+ * content (like `pdfFooter()` in ./components) prints an empty page slot.
41
+ *
42
+ * The templates are mini-documents, not part of the page: they start at
43
+ * `font-size: 0`, need their own `print-color-adjust: exact` to keep
44
+ * backgrounds, and only `<span class="pageNumber">` / `<span class="totalPages">`
45
+ * get substituted. Keep `margin.top`/`margin.bottom` larger than the templates,
46
+ * or the page content prints over them.
47
+ */
48
+ displayHeaderFooter?: boolean;
49
+ headerTemplate?: string;
50
+ footerTemplate?: string;
51
+
52
+ /** `page.setContent` wait condition. Defaults to 'networkidle'. */
53
+ waitUntil?: 'load' | 'domcontentloaded' | 'networkidle' | 'commit';
54
+ /** Ceiling for `page.setContent`, in ms. Defaults to Playwright's own (30s). */
55
+ timeoutMs?: number;
56
+ scale?: number;
57
+ pageRanges?: string;
58
+ /** Short identifier for the logs when a render fails or the queue saturates. */
59
+ label?: string;
23
60
  }
24
61
 
62
+ /** A render waiting for a Chromium slot. */
63
+ type QueuedRender = {
64
+ resolve: () => void;
65
+ reject: (error: Error) => void;
66
+ timer: ReturnType<typeof setTimeout>;
67
+ };
68
+
25
69
  // ─────────────────────────────────────────────────────────────────────────────
26
70
  // Helpers
27
71
  // ─────────────────────────────────────────────────────────────────────────────
@@ -33,6 +77,55 @@ function parsePxSize(value: string | undefined, fallback: number): number {
33
77
  return match ? Math.round(Number(match[1])) : fallback;
34
78
  }
35
79
 
80
+ const ZERO_MARGIN = { top: '0', right: '0', bottom: '0', left: '0' };
81
+
82
+ /**
83
+ * With `displayHeaderFooter` on, a template left undefined makes Chromium fall
84
+ * back to its own — the page URL and the print date. The unused slot gets
85
+ * something deliberately empty instead.
86
+ */
87
+ const EMPTY_TEMPLATE = '<span></span>';
88
+
89
+ const DEFAULT_MAX_CONCURRENCY = 2;
90
+ const DEFAULT_QUEUE_WAIT_MS = 30_000;
91
+
92
+ /**
93
+ * Builds the object handed to `page.pdf()`.
94
+ *
95
+ * Assembled key by key on purpose: a caller that passes none of the optional
96
+ * fields has to get exactly the object `page.pdf()` received before they
97
+ * existed — Chromium changes behaviour on the mere presence of some of them
98
+ * (`displayHeaderFooter` with a single template filled in brings back its own
99
+ * URL/date footer).
100
+ */
101
+ export function buildPdfPageOptions(options: PdfGenerateOptions): Record<string, unknown> {
102
+ const pdfOptions: Record<string, unknown> =
103
+ options.width && options.height
104
+ ? {
105
+ width: options.width,
106
+ height: options.height,
107
+ printBackground: options.printBackground ?? true,
108
+ margin: options.margin ?? ZERO_MARGIN,
109
+ }
110
+ : {
111
+ format: options.format ?? 'A4',
112
+ printBackground: options.printBackground ?? true,
113
+ landscape: options.landscape ?? false,
114
+ margin: options.margin ?? ZERO_MARGIN,
115
+ };
116
+
117
+ if (options.displayHeaderFooter) {
118
+ pdfOptions.displayHeaderFooter = true;
119
+ pdfOptions.headerTemplate = options.headerTemplate ?? EMPTY_TEMPLATE;
120
+ pdfOptions.footerTemplate = options.footerTemplate ?? EMPTY_TEMPLATE;
121
+ }
122
+
123
+ if (options.scale !== undefined) pdfOptions.scale = options.scale;
124
+ if (options.pageRanges) pdfOptions.pageRanges = options.pageRanges;
125
+
126
+ return pdfOptions;
127
+ }
128
+
36
129
  // ─────────────────────────────────────────────────────────────────────────────
37
130
  // Service
38
131
  // ─────────────────────────────────────────────────────────────────────────────
@@ -41,6 +134,10 @@ function parsePxSize(value: string | undefined, fallback: number): number {
41
134
  export class PdfService {
42
135
  private readonly logger = new Logger(PdfService.name);
43
136
 
137
+ /** Renders holding a Chromium right now, and the ones waiting for a slot. */
138
+ private activeRenders = 0;
139
+ private readonly renderQueue: QueuedRender[] = [];
140
+
44
141
  constructor(
45
142
  @Inject(forwardRef(() => SettingService))
46
143
  private readonly settingService: SettingService,
@@ -120,15 +217,136 @@ export class PdfService {
120
217
  }
121
218
  }
122
219
 
123
- // ── HTML → PDF ────────────────────────────────────────────────────────────
220
+ /**
221
+ * Theme for commercial documents (proposals and the like): the area theme
222
+ * plus the legal footer, partner line and font stacks of the `pdf` group.
223
+ * The logo is embedded as a data URI so the render does not depend on the
224
+ * API reaching its own public URL.
225
+ */
226
+ async loadCommercialDocumentTheme(
227
+ area: PdfAreaType = 'training',
228
+ ): Promise<CommercialDocumentTheme> {
229
+ const base = await this.loadThemeConfig(area);
230
+
231
+ let values: Record<string, any> = {};
232
+ try {
233
+ values = await this.settingService.getSettingValues([
234
+ 'pdf-legal-footer',
235
+ 'pdf-partner-line',
236
+ 'pdf-font-family',
237
+ 'pdf-font-mono',
238
+ ]);
239
+ } catch {
240
+ this.logger.warn('Could not load commercial PDF settings – using defaults.');
241
+ }
242
+
243
+ const logoOverride = base.logoOverride
244
+ ? ((await this.brandAsset.fetchAsDataUri(base.logoOverride)) ?? base.logoOverride)
245
+ : '';
246
+
247
+ return {
248
+ ...base,
249
+ logoOverride,
250
+ legalFooter: String(values['pdf-legal-footer'] ?? '').trim(),
251
+ partnerLine: String(values['pdf-partner-line'] ?? '').trim(),
252
+ fontFamily: String(values['pdf-font-family'] ?? '').trim() || "'Inter', sans-serif",
253
+ fontMono: String(values['pdf-font-mono'] ?? '').trim() || "'JetBrains Mono', monospace",
254
+ };
255
+ }
124
256
 
125
257
  /**
126
- * Renders an HTML string to a PDF buffer using Playwright/Chromium.
258
+ * Renders a commercial proposal with the installation's branding. Returns
259
+ * the HTML too, so the caller can keep the exact source of what was printed.
260
+ */
261
+ async renderCommercialProposal(
262
+ data: CommercialProposalData,
263
+ area: PdfAreaType = 'training',
264
+ ): Promise<{ html: string; buffer: Buffer }> {
265
+ const theme = await this.loadCommercialDocumentTheme(area);
266
+ const { html, pdfOptions } = commercialProposalTemplate(data, theme);
267
+ const buffer = await this.generatePdfFromHtml(html, pdfOptions);
268
+ return { html, buffer };
269
+ }
270
+
271
+ // ── Chromium runtime ──────────────────────────────────────────────────────
272
+
273
+ private get maxConcurrency(): number {
274
+ const raw = Number(this.configService.get<string>('PDF_MAX_CONCURRENCY'));
275
+ return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : DEFAULT_MAX_CONCURRENCY;
276
+ }
277
+
278
+ private get queueWaitMs(): number {
279
+ const raw = Number(this.configService.get<string>('PDF_QUEUE_WAIT_MS'));
280
+ return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : DEFAULT_QUEUE_WAIT_MS;
281
+ }
282
+
283
+ /**
284
+ * Every render launches a Chromium of its own, which costs a few hundred MB.
285
+ * The API pod is capped well below what a burst of simultaneous report
286
+ * downloads would ask for, and an OOM kill takes the whole API down — not
287
+ * just the PDF. So renders queue, and a queue that waits too long answers a
288
+ * retryable 503 instead of dragging the process down with it.
289
+ */
290
+ private async acquireRenderSlot(label?: string): Promise<() => void> {
291
+ if (this.activeRenders < this.maxConcurrency) {
292
+ this.activeRenders += 1;
293
+ } else {
294
+ await new Promise<void>((resolve, reject) => {
295
+ const entry: QueuedRender = {
296
+ resolve,
297
+ reject,
298
+ timer: setTimeout(() => {
299
+ const index = this.renderQueue.indexOf(entry);
300
+ if (index >= 0) this.renderQueue.splice(index, 1);
301
+ this.logger.warn(
302
+ `PDF render queue is saturated; gave up after ${this.queueWaitMs}ms${label ? ` (${label})` : ''}.`,
303
+ );
304
+ reject(
305
+ new ServiceUnavailableException(
306
+ 'The server is busy rendering other documents. Try again in a moment.',
307
+ ),
308
+ );
309
+ }, this.queueWaitMs),
310
+ };
311
+ this.renderQueue.push(entry);
312
+ });
313
+ // Resolved by releaseRenderSlot, which hands its slot over without
314
+ // decrementing — the counter already accounts for this render.
315
+ }
316
+
317
+ let released = false;
318
+ return () => {
319
+ if (released) return;
320
+ released = true;
321
+ this.releaseRenderSlot();
322
+ };
323
+ }
324
+
325
+ private releaseRenderSlot(): void {
326
+ const next = this.renderQueue.shift();
327
+ if (next) {
328
+ clearTimeout(next.timer);
329
+ next.resolve();
330
+ return;
331
+ }
332
+ this.activeRenders = Math.max(0, this.activeRenders - 1);
333
+ }
334
+
335
+ /**
336
+ * Opens a Chromium page with the HTML loaded and hands it to `run`, taking
337
+ * care of the slot, the viewport and the teardown.
127
338
  *
128
339
  * Uses dynamic `import()` via `new Function` to remain compatible with
129
340
  * CommonJS bundle output (same pattern as operations.service.ts).
130
341
  */
131
- async generatePdfFromHtml(html: string, options: PdfGenerateOptions = {}): Promise<Buffer> {
342
+ private async renderWithChromium(
343
+ kind: 'PDF' | 'Image',
344
+ html: string,
345
+ options: PdfGenerateOptions,
346
+ run: (page: any) => Promise<any>,
347
+ ): Promise<Buffer> {
348
+ const release = await this.acquireRenderSlot(options.label);
349
+
132
350
  const executablePath = this.configService.get<string>('CHROMIUM_EXECUTABLE_PATH');
133
351
  const noSandbox = this.configService.get<string>('CHROMIUM_NO_SANDBOX') === 'true';
134
352
 
@@ -163,25 +381,12 @@ export class PdfService {
163
381
  width: parsePxSize(options.width, 794),
164
382
  height: parsePxSize(options.height, 1123),
165
383
  });
166
- await page.setContent(html, { waitUntil: 'networkidle' });
167
-
168
- const pdfBuffer = await page.pdf(
169
- options.width && options.height
170
- ? {
171
- width: options.width,
172
- height: options.height,
173
- printBackground: options.printBackground ?? true,
174
- margin: options.margin ?? { top: '0', right: '0', bottom: '0', left: '0' },
175
- }
176
- : {
177
- format: options.format ?? 'A4',
178
- printBackground: options.printBackground ?? true,
179
- landscape: options.landscape ?? false,
180
- margin: options.margin ?? { top: '0', right: '0', bottom: '0', left: '0' },
181
- },
182
- );
384
+ await page.setContent(html, {
385
+ waitUntil: options.waitUntil ?? 'networkidle',
386
+ ...(options.timeoutMs ? { timeout: options.timeoutMs } : {}),
387
+ });
183
388
 
184
- return Buffer.from(pdfBuffer);
389
+ return Buffer.from(await run(page));
185
390
  } catch (error) {
186
391
  const errorMessage = error instanceof Error ? error.message : String(error);
187
392
  const errorStack = error instanceof Error ? (error.stack ?? errorMessage) : String(error);
@@ -191,7 +396,7 @@ export class PdfService {
191
396
  errorMessage,
192
397
  );
193
398
 
194
- this.logger.error(`PDF generation failed. ${errorMessage}`, errorStack);
399
+ this.logger.error(`${kind} generation failed. ${errorMessage}`, errorStack);
195
400
 
196
401
  if (missingRuntime) {
197
402
  throw new InternalServerErrorException(
@@ -199,7 +404,7 @@ export class PdfService {
199
404
  );
200
405
  }
201
406
 
202
- throw new InternalServerErrorException(`PDF generation failed: ${errorMessage}`);
407
+ throw new InternalServerErrorException(`${kind} generation failed: ${errorMessage}`);
203
408
  } finally {
204
409
  if (browser) {
205
410
  try {
@@ -208,9 +413,21 @@ export class PdfService {
208
413
  // Ignore close errors
209
414
  }
210
415
  }
416
+ release();
211
417
  }
212
418
  }
213
419
 
420
+ // ── HTML → PDF ────────────────────────────────────────────────────────────
421
+
422
+ /**
423
+ * Renders an HTML string to a PDF buffer using Playwright/Chromium.
424
+ */
425
+ async generatePdfFromHtml(html: string, options: PdfGenerateOptions = {}): Promise<Buffer> {
426
+ const pdfOptions = buildPdfPageOptions(options);
427
+
428
+ return this.renderWithChromium('PDF', html, options, (page) => page.pdf(pdfOptions));
429
+ }
430
+
214
431
  // ── HTML → PNG image ──────────────────────────────────────────────────────
215
432
 
216
433
  /**
@@ -221,69 +438,15 @@ export class PdfService {
221
438
  * certificate thumbnails on the web/app.
222
439
  */
223
440
  async generateImageFromHtml(html: string, options: PdfGenerateOptions = {}): Promise<Buffer> {
224
- const executablePath = this.configService.get<string>('CHROMIUM_EXECUTABLE_PATH');
225
- const noSandbox = this.configService.get<string>('CHROMIUM_NO_SANDBOX') === 'true';
226
-
227
- const launchArgs = noSandbox
228
- ? ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage']
229
- : [];
230
-
231
441
  const width = parsePxSize(options.width, 794);
232
442
  const height = parsePxSize(options.height, 1123);
233
443
 
234
- let browser: any = null;
235
-
236
- try {
237
- const importPlaywright = new Function(
238
- 'moduleName',
239
- 'return import(moduleName);',
240
- ) as (moduleName: string) => Promise<any>;
241
-
242
- const playwright = await importPlaywright('playwright');
243
-
244
- browser = await playwright.chromium.launch({
245
- headless: true,
246
- ...(executablePath ? { executablePath } : {}),
247
- args: launchArgs,
248
- });
249
-
250
- const page = await browser.newPage();
251
- await page.setViewportSize({ width, height });
252
- await page.setContent(html, { waitUntil: 'networkidle' });
253
-
254
- const imageBuffer = await page.screenshot({
444
+ return this.renderWithChromium('Image', html, options, (page) =>
445
+ page.screenshot({
255
446
  type: 'png',
256
447
  clip: { x: 0, y: 0, width, height },
257
- });
258
-
259
- return Buffer.from(imageBuffer);
260
- } catch (error) {
261
- const errorMessage = error instanceof Error ? error.message : String(error);
262
- const errorStack = error instanceof Error ? (error.stack ?? errorMessage) : String(error);
263
-
264
- const missingRuntime =
265
- /Cannot find package ['"]playwright['"]|Cannot find module ['"]playwright['"]|Executable doesn't exist|browserType\.launch|Failed to launch|Please run the following command/i.test(
266
- errorMessage,
267
- );
268
-
269
- this.logger.error(`Image generation failed. ${errorMessage}`, errorStack);
270
-
271
- if (missingRuntime) {
272
- throw new InternalServerErrorException(
273
- 'Playwright runtime is not available. Run `pnpm --filter api run playwright:install` in the API environment.',
274
- );
275
- }
276
-
277
- throw new InternalServerErrorException(`Image generation failed: ${errorMessage}`);
278
- } finally {
279
- if (browser) {
280
- try {
281
- await browser.close();
282
- } catch {
283
- // Ignore close errors
284
- }
285
- }
286
- }
448
+ }),
449
+ );
287
450
  }
288
451
 
289
452
  // ── Template engine ───────────────────────────────────────────────────────
@@ -0,0 +1,177 @@
1
+ import { describe, expect, it, jest } from '@jest/globals';
2
+
3
+ import { PdfService } from '../pdf.service';
4
+ import {
5
+ CommercialDocumentTheme,
6
+ CommercialProposalData,
7
+ commercialProposalTemplate,
8
+ } from './commercial-proposal.template';
9
+
10
+ const theme = (overrides: Partial<CommercialDocumentTheme> = {}): CommercialDocumentTheme => ({
11
+ area: 'training',
12
+ companyName: 'hcode',
13
+ logoOverride: '',
14
+ primaryColor: '#FF760C',
15
+ darkColor: '#C45400',
16
+ lightColor: '#FFF0E2',
17
+ legalFooter: '',
18
+ partnerLine: '',
19
+ fontFamily: "'Inter', sans-serif",
20
+ fontMono: "'JetBrains Mono', monospace",
21
+ ...overrides,
22
+ });
23
+
24
+ const data = (overrides: Partial<CommercialProposalData> = {}): CommercialProposalData => ({
25
+ eyebrow: 'Proposta Comercial · BRA20260865',
26
+ title: 'Workshop de IA',
27
+ titleAccent: 'ArcelorMittal',
28
+ meta: [
29
+ { label: 'Cliente', value: 'ArcelorMittal' },
30
+ { label: 'Contato', value: '' },
31
+ ],
32
+ sections: [
33
+ { eyebrow: 'Sumário executivo', title: 'O que propomos.', blocks: [{ type: 'paragraphs', text: 'Um dia.' }] },
34
+ { eyebrow: 'Vazia', title: 'Não aparece.', blocks: [] },
35
+ {
36
+ eyebrow: 'Investimento',
37
+ title: 'Itens e valores.',
38
+ blocks: [
39
+ { type: 'table', headers: ['Item', 'Total'], rows: [['Workshop', 'R$ 1,00']], align: ['left', 'right'] },
40
+ { type: 'totals', rows: [{ label: 'Total', value: 'R$ 1,00', emphasis: true }] },
41
+ ],
42
+ },
43
+ ],
44
+ ...overrides,
45
+ });
46
+
47
+ describe('commercialProposalTemplate', () => {
48
+ it('numera só as seções com conteúdo, na ordem', () => {
49
+ const { html } = commercialProposalTemplate(data(), theme());
50
+
51
+ expect(html).toContain('01 · SUMÁRIO EXECUTIVO');
52
+ expect(html).toContain('02 · INVESTIMENTO');
53
+ expect(html).not.toContain('Não aparece.');
54
+ });
55
+
56
+ it('escapa o texto e deixa passar só o bloco html', () => {
57
+ const { html } = commercialProposalTemplate(
58
+ data({
59
+ title: '<script>alert(1)</script>',
60
+ sections: [
61
+ {
62
+ eyebrow: 'Resumo',
63
+ title: 'Texto.',
64
+ blocks: [
65
+ { type: 'paragraphs', text: '<b>cru</b>' },
66
+ { type: 'html', html: '<p><strong>rico</strong></p>' },
67
+ ],
68
+ },
69
+ ],
70
+ }),
71
+ theme(),
72
+ );
73
+
74
+ expect(html).not.toContain('<script>alert(1)</script>');
75
+ expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
76
+ expect(html).toContain('&lt;b&gt;cru&lt;/b&gt;');
77
+ expect(html).toContain('<p><strong>rico</strong></p>');
78
+ });
79
+
80
+ it('omite metadado vazio na capa', () => {
81
+ const { html } = commercialProposalTemplate(data(), theme());
82
+
83
+ expect(html).toContain('<th>CLIENTE</th>');
84
+ expect(html).not.toContain('<th>CONTATO</th>');
85
+ });
86
+
87
+ it('usa a logo quando há e o nome da empresa como wordmark quando não há', () => {
88
+ expect(commercialProposalTemplate(data(), theme({ logoOverride: 'data:image/png;base64,AA' })).html).toContain(
89
+ '<img class="brand-logo" src="data:image/png;base64,AA"',
90
+ );
91
+
92
+ const { html } = commercialProposalTemplate(data(), theme());
93
+ expect(html).toContain('<span class="accent">h</span>code');
94
+ expect(html).toContain(' | TRAINING');
95
+ });
96
+
97
+ it('põe o rodapé legal e a paginação n / N no rodapé de cada página', () => {
98
+ const { pdfOptions } = commercialProposalTemplate(
99
+ data(),
100
+ theme({ legalFooter: 'Grupo Hcode · CNPJ 24.700.731/0001-08' }),
101
+ );
102
+
103
+ expect(pdfOptions.displayHeaderFooter).toBe(true);
104
+ expect(pdfOptions.footerTemplate).toContain('GRUPO HCODE · CNPJ 24.700.731/0001-08');
105
+ expect(pdfOptions.footerTemplate).toContain('<span class="pageNumber"></span> / <span class="totalPages"></span>');
106
+ expect(pdfOptions.headerTemplate).toContain('background:#FF760C');
107
+ });
108
+
109
+ it('cai no rótulo do documento quando não há rodapé legal', () => {
110
+ const { pdfOptions } = commercialProposalTemplate(data({ documentLabel: 'hcode · Proposta' }), theme());
111
+
112
+ expect(pdfOptions.footerTemplate).toContain('HCODE · PROPOSTA');
113
+ });
114
+
115
+ it('mostra a linha de parceiros só quando configurada', () => {
116
+ expect(commercialProposalTemplate(data(), theme()).html).not.toContain('partner-line">');
117
+ expect(
118
+ commercialProposalTemplate(data(), theme({ partnerLine: 'Parceira oficial Anthropic' })).html,
119
+ ).toContain('PARCEIRA OFICIAL ANTHROPIC');
120
+ });
121
+
122
+ it('não deixa uma setting fora do formato quebrar o CSS', () => {
123
+ const { html } = commercialProposalTemplate(
124
+ data(),
125
+ theme({ primaryColor: 'red;}</style><script>', fontFamily: 'Arial;}</style>' }),
126
+ );
127
+
128
+ expect(html).toContain('--primary: #FF760C;');
129
+ expect(html).toContain('--font: Arial/style;');
130
+ expect(html).not.toContain('</style><script>');
131
+ });
132
+ });
133
+
134
+ describe('PdfService.loadCommercialDocumentTheme', () => {
135
+ const makeService = (values: Record<string, unknown>, dataUri: string | null) => {
136
+ const settingService = { getSettingValues: jest.fn(async () => values) };
137
+ const brandAsset = {
138
+ resolveAssetUrl: jest.fn(async (url: string) => `https://api.example.com${url}`),
139
+ fetchAsDataUri: jest.fn(async () => dataUri),
140
+ };
141
+ return new PdfService(settingService as any, brandAsset as any, { get: () => undefined } as any);
142
+ };
143
+
144
+ it('lê rodapé, parceiros e fontes das settings e embute a logo', async () => {
145
+ const service = makeService(
146
+ {
147
+ 'pdf-company-name': 'hcode',
148
+ 'pdf-logo': '/logo.svg',
149
+ 'pdf-legal-footer': 'Grupo Hcode',
150
+ 'pdf-partner-line': 'Parceira Anthropic',
151
+ 'pdf-font-family': 'Roboto, sans-serif',
152
+ 'pdf-font-mono': '',
153
+ },
154
+ 'data:image/svg+xml;base64,AA',
155
+ );
156
+
157
+ const result = await service.loadCommercialDocumentTheme('training');
158
+
159
+ expect(result).toMatchObject({
160
+ companyName: 'hcode',
161
+ logoOverride: 'data:image/svg+xml;base64,AA',
162
+ legalFooter: 'Grupo Hcode',
163
+ partnerLine: 'Parceira Anthropic',
164
+ fontFamily: 'Roboto, sans-serif',
165
+ fontMono: "'JetBrains Mono', monospace",
166
+ });
167
+ });
168
+
169
+ it('mantém a URL da logo quando não consegue embutir', async () => {
170
+ const service = makeService({ 'pdf-logo': '/logo.svg' }, null);
171
+
172
+ const result = await service.loadCommercialDocumentTheme('training');
173
+
174
+ expect(result.logoOverride).toBe('https://api.example.com/logo.svg');
175
+ expect(result.legalFooter).toBe('');
176
+ });
177
+ });