@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.
- package/dist/ai/ai.controller.d.ts +2 -0
- package/dist/ai/ai.controller.d.ts.map +1 -1
- package/dist/ai/ai.service.d.ts +86 -2
- package/dist/ai/ai.service.d.ts.map +1 -1
- package/dist/ai/ai.service.js +304 -23
- package/dist/ai/ai.service.js.map +1 -1
- package/dist/ai-instruction/ai-prompt-resolver.service.d.ts +12 -0
- package/dist/ai-instruction/ai-prompt-resolver.service.d.ts.map +1 -1
- package/dist/ai-instruction/ai-prompt-resolver.service.js +26 -0
- package/dist/ai-instruction/ai-prompt-resolver.service.js.map +1 -1
- package/dist/core.module.d.ts.map +1 -1
- package/dist/core.module.js +3 -0
- package/dist/core.module.js.map +1 -1
- package/dist/document-kit/document-kit.module.d.ts +3 -0
- package/dist/document-kit/document-kit.module.d.ts.map +1 -0
- package/dist/document-kit/document-kit.module.js +24 -0
- package/dist/document-kit/document-kit.module.js.map +1 -0
- package/dist/document-kit/document-kit.registry.d.ts +55 -0
- package/dist/document-kit/document-kit.registry.d.ts.map +1 -0
- package/dist/document-kit/document-kit.registry.js +91 -0
- package/dist/document-kit/document-kit.registry.js.map +1 -0
- package/dist/document-kit/document-kit.service.d.ts +49 -0
- package/dist/document-kit/document-kit.service.d.ts.map +1 -0
- package/dist/document-kit/document-kit.service.js +135 -0
- package/dist/document-kit/document-kit.service.js.map +1 -0
- package/dist/file/file.service.d.ts +15 -1
- package/dist/file/file.service.d.ts.map +1 -1
- package/dist/file/file.service.js +52 -24
- package/dist/file/file.service.js.map +1 -1
- package/dist/file/mcp-file-source.schema.d.ts.map +1 -1
- package/dist/file/mcp-file-source.schema.js +8 -5
- package/dist/file/mcp-file-source.schema.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
- package/dist/integration-profile/integration-profile.secrets.js +21 -0
- package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
- package/dist/integration-profile/testers/ai.testers.d.ts.map +1 -1
- package/dist/integration-profile/testers/ai.testers.js +46 -8
- package/dist/integration-profile/testers/ai.testers.js.map +1 -1
- package/dist/mcp/mcp.module.d.ts.map +1 -1
- package/dist/mcp/mcp.module.js +2 -0
- package/dist/mcp/mcp.module.js.map +1 -1
- package/dist/mcp/tools/core-access-log.mcp-tools.d.ts +17 -0
- package/dist/mcp/tools/core-access-log.mcp-tools.d.ts.map +1 -0
- package/dist/mcp/tools/core-access-log.mcp-tools.js +74 -0
- package/dist/mcp/tools/core-access-log.mcp-tools.js.map +1 -0
- package/dist/mcp/tools/core-files.mcp-tools.js +4 -4
- package/dist/mcp/tools/core-files.mcp-tools.js.map +1 -1
- package/dist/meeting/meeting-provider.registry.d.ts +72 -0
- package/dist/meeting/meeting-provider.registry.d.ts.map +1 -1
- package/dist/meeting/meeting-provider.registry.js.map +1 -1
- package/dist/oauth/oauth-token-broker.service.d.ts +50 -1
- package/dist/oauth/oauth-token-broker.service.d.ts.map +1 -1
- package/dist/oauth/oauth-token-broker.service.js +105 -3
- package/dist/oauth/oauth-token-broker.service.js.map +1 -1
- package/dist/oauth/providers/microsoft-entra-id.provider.d.ts +6 -0
- package/dist/oauth/providers/microsoft-entra-id.provider.d.ts.map +1 -1
- package/dist/oauth/providers/microsoft-entra-id.provider.js +30 -4
- package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
- package/dist/pdf/document/charts/chart-bar.d.ts +24 -0
- package/dist/pdf/document/charts/chart-bar.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-bar.js +117 -0
- package/dist/pdf/document/charts/chart-bar.js.map +1 -0
- package/dist/pdf/document/charts/chart-donut.d.ts +16 -0
- package/dist/pdf/document/charts/chart-donut.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-donut.js +89 -0
- package/dist/pdf/document/charts/chart-donut.js.map +1 -0
- package/dist/pdf/document/charts/chart-gauge.d.ts +15 -0
- package/dist/pdf/document/charts/chart-gauge.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-gauge.js +60 -0
- package/dist/pdf/document/charts/chart-gauge.js.map +1 -0
- package/dist/pdf/document/charts/chart-line.d.ts +29 -0
- package/dist/pdf/document/charts/chart-line.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-line.js +89 -0
- package/dist/pdf/document/charts/chart-line.js.map +1 -0
- package/dist/pdf/document/charts/chart-primitives.d.ts +64 -0
- package/dist/pdf/document/charts/chart-primitives.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-primitives.js +110 -0
- package/dist/pdf/document/charts/chart-primitives.js.map +1 -0
- package/dist/pdf/document/charts/chart-radar.d.ts +13 -0
- package/dist/pdf/document/charts/chart-radar.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-radar.js +83 -0
- package/dist/pdf/document/charts/chart-radar.js.map +1 -0
- package/dist/pdf/document/charts/chart-scale.d.ts +39 -0
- package/dist/pdf/document/charts/chart-scale.d.ts.map +1 -0
- package/dist/pdf/document/charts/chart-scale.js +118 -0
- package/dist/pdf/document/charts/chart-scale.js.map +1 -0
- package/dist/pdf/document/charts/index.d.ts +8 -0
- package/dist/pdf/document/charts/index.d.ts.map +1 -0
- package/dist/pdf/document/charts/index.js +24 -0
- package/dist/pdf/document/charts/index.js.map +1 -0
- package/dist/pdf/document/index.d.ts +8 -0
- package/dist/pdf/document/index.d.ts.map +1 -0
- package/dist/pdf/document/index.js +24 -0
- package/dist/pdf/document/index.js.map +1 -0
- package/dist/pdf/document/pdf-document.chrome.d.ts +20 -0
- package/dist/pdf/document/pdf-document.chrome.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.chrome.js +36 -0
- package/dist/pdf/document/pdf-document.chrome.js.map +1 -0
- package/dist/pdf/document/pdf-document.css.d.ts +20 -0
- package/dist/pdf/document/pdf-document.css.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.css.js +239 -0
- package/dist/pdf/document/pdf-document.css.js.map +1 -0
- package/dist/pdf/document/pdf-document.renderer.d.ts +10 -0
- package/dist/pdf/document/pdf-document.renderer.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.renderer.js +153 -0
- package/dist/pdf/document/pdf-document.renderer.js.map +1 -0
- package/dist/pdf/document/pdf-document.service.d.ts +27 -0
- package/dist/pdf/document/pdf-document.service.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.service.js +81 -0
- package/dist/pdf/document/pdf-document.service.js.map +1 -0
- package/dist/pdf/document/pdf-document.theme.d.ts +41 -0
- package/dist/pdf/document/pdf-document.theme.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.theme.js +105 -0
- package/dist/pdf/document/pdf-document.theme.js.map +1 -0
- package/dist/pdf/document/pdf-document.types.d.ts +108 -0
- package/dist/pdf/document/pdf-document.types.d.ts.map +1 -0
- package/dist/pdf/document/pdf-document.types.js +18 -0
- package/dist/pdf/document/pdf-document.types.js.map +1 -0
- package/dist/pdf/pdf.module.d.ts.map +1 -1
- package/dist/pdf/pdf.module.js +3 -2
- package/dist/pdf/pdf.module.js.map +1 -1
- package/dist/pdf/pdf.service.d.ts +69 -1
- package/dist/pdf/pdf.service.d.ts.map +1 -1
- package/dist/pdf/pdf.service.js +164 -63
- package/dist/pdf/pdf.service.js.map +1 -1
- package/dist/pdf/templates/commercial-proposal.template.d.ts +96 -0
- package/dist/pdf/templates/commercial-proposal.template.d.ts.map +1 -0
- package/dist/pdf/templates/commercial-proposal.template.js +276 -0
- package/dist/pdf/templates/commercial-proposal.template.js.map +1 -0
- package/dist/setting/favicon.controller.d.ts +26 -0
- package/dist/setting/favicon.controller.d.ts.map +1 -0
- package/dist/setting/favicon.controller.js +130 -0
- package/dist/setting/favicon.controller.js.map +1 -0
- package/dist/setting/setting.module.d.ts.map +1 -1
- package/dist/setting/setting.module.js +2 -1
- package/dist/setting/setting.module.js.map +1 -1
- package/dist/whatsapp-sender/whatsapp-sender.service.d.ts.map +1 -1
- package/dist/whatsapp-sender/whatsapp-sender.service.js +25 -2
- package/dist/whatsapp-sender/whatsapp-sender.service.js.map +1 -1
- package/hedhog/data/integration_type.yaml +6 -0
- package/hedhog/data/route.yaml +8 -0
- package/hedhog/data/setting_group.yaml +52 -0
- package/hedhog/frontend/messages/en.json +54 -1
- package/hedhog/frontend/messages/pt.json +54 -1
- package/package.json +4 -4
- package/src/ai/ai-attachment.fragments.spec.ts +56 -3
- package/src/ai/ai.service.responses.spec.ts +429 -0
- package/src/ai/ai.service.ts +409 -27
- package/src/ai-instruction/ai-prompt-resolver.service.spec.ts +44 -0
- package/src/ai-instruction/ai-prompt-resolver.service.ts +29 -0
- package/src/core.module.ts +3 -0
- package/src/document-kit/document-kit.module.ts +12 -0
- package/src/document-kit/document-kit.registry.ts +125 -0
- package/src/document-kit/document-kit.service.spec.ts +156 -0
- package/src/document-kit/document-kit.service.ts +173 -0
- package/src/file/file.service.spec.ts +168 -1
- package/src/file/file.service.ts +72 -34
- package/src/file/mcp-file-source.schema.ts +8 -5
- package/src/index.ts +5 -0
- package/src/integration-profile/integration-profile.secrets.ts +21 -0
- package/src/integration-profile/testers/ai.testers.ts +70 -5
- package/src/integration-profile/testers/remaining.testers.spec.ts +65 -1
- package/src/integration-profile/testers/tester-coverage.spec.ts +10 -0
- package/src/mcp/mcp.module.ts +2 -0
- package/src/mcp/tools/core-access-log.mcp-tools.ts +66 -0
- package/src/mcp/tools/core-files.mcp-tools.ts +4 -4
- package/src/meeting/meeting-provider.registry.ts +80 -0
- package/src/oauth/oauth-token-broker.service.spec.ts +108 -0
- package/src/oauth/oauth-token-broker.service.ts +129 -2
- package/src/oauth/providers/microsoft-entra-id.provider.spec.ts +133 -0
- package/src/oauth/providers/microsoft-entra-id.provider.ts +29 -2
- package/src/pdf/document/charts/chart-bar.ts +217 -0
- package/src/pdf/document/charts/chart-donut.ts +163 -0
- package/src/pdf/document/charts/chart-gauge.ts +94 -0
- package/src/pdf/document/charts/chart-line.ts +159 -0
- package/src/pdf/document/charts/chart-primitives.ts +188 -0
- package/src/pdf/document/charts/chart-radar.ts +136 -0
- package/src/pdf/document/charts/chart-scale.spec.ts +93 -0
- package/src/pdf/document/charts/chart-scale.ts +141 -0
- package/src/pdf/document/charts/charts.spec.ts +157 -0
- package/src/pdf/document/charts/index.ts +7 -0
- package/src/pdf/document/index.ts +7 -0
- package/src/pdf/document/pdf-document.chrome.ts +38 -0
- package/src/pdf/document/pdf-document.css.ts +238 -0
- package/src/pdf/document/pdf-document.renderer.spec.ts +129 -0
- package/src/pdf/document/pdf-document.renderer.ts +215 -0
- package/src/pdf/document/pdf-document.service.ts +77 -0
- package/src/pdf/document/pdf-document.theme.ts +146 -0
- package/src/pdf/document/pdf-document.types.ts +97 -0
- package/src/pdf/pdf.module.ts +3 -2
- package/src/pdf/pdf.service.spec.ts +131 -0
- package/src/pdf/pdf.service.ts +245 -82
- package/src/pdf/templates/commercial-proposal.template.spec.ts +177 -0
- package/src/pdf/templates/commercial-proposal.template.ts +400 -0
- package/src/setting/favicon.controller.spec.ts +92 -0
- package/src/setting/favicon.controller.ts +135 -0
- package/src/setting/setting.module.ts +2 -1
- package/src/whatsapp-sender/whatsapp-sender.service.spec.ts +73 -0
- package/src/whatsapp-sender/whatsapp-sender.service.ts +32 -2
package/src/pdf/pdf.service.ts
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
|
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, {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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('<script>alert(1)</script>');
|
|
76
|
+
expect(html).toContain('<b>cru</b>');
|
|
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
|
+
});
|