@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
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { buildDocumentCss } from './pdf-document.css';
|
|
2
|
+
import { PdfDocumentTheme } from './pdf-document.theme';
|
|
3
|
+
import {
|
|
4
|
+
PdfDocumentBlock,
|
|
5
|
+
PdfDocumentCard,
|
|
6
|
+
PdfDocumentInput,
|
|
7
|
+
PdfDocumentRow,
|
|
8
|
+
PdfDocumentTableCell,
|
|
9
|
+
PdfDocumentTableColumn,
|
|
10
|
+
} from './pdf-document.types';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Único ponto por onde texto entra no HTML. Nome de aluno, título de curso e
|
|
14
|
+
* rótulo de tarefa são dados de usuário indo para um Chromium headless.
|
|
15
|
+
*/
|
|
16
|
+
export function escapeHtml(value: unknown): string {
|
|
17
|
+
if (value === null || value === undefined) return '';
|
|
18
|
+
|
|
19
|
+
return String(value)
|
|
20
|
+
.replace(/&/g, '&')
|
|
21
|
+
.replace(/</g, '<')
|
|
22
|
+
.replace(/>/g, '>')
|
|
23
|
+
.replace(/"/g, '"')
|
|
24
|
+
.replace(/'/g, ''');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const formatGeneratedAt = (date: Date, locale: string): string =>
|
|
28
|
+
new Intl.DateTimeFormat(locale, {
|
|
29
|
+
day: '2-digit',
|
|
30
|
+
month: 'long',
|
|
31
|
+
year: 'numeric',
|
|
32
|
+
hour: '2-digit',
|
|
33
|
+
minute: '2-digit',
|
|
34
|
+
}).format(date);
|
|
35
|
+
|
|
36
|
+
const toCell = (value: PdfDocumentRow[number]): PdfDocumentTableCell => {
|
|
37
|
+
if (value === null || value === undefined) return { text: '—', tone: 'muted' };
|
|
38
|
+
if (typeof value === 'object') return value;
|
|
39
|
+
return { text: String(value) };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const alignClass = (column: PdfDocumentTableColumn): string =>
|
|
43
|
+
column.align === 'right'
|
|
44
|
+
? ' class="align-right"'
|
|
45
|
+
: column.align === 'center'
|
|
46
|
+
? ' class="align-center"'
|
|
47
|
+
: '';
|
|
48
|
+
|
|
49
|
+
function renderCards(items: PdfDocumentCard[], columns: number, variant: string): string {
|
|
50
|
+
const cards = items
|
|
51
|
+
.map(
|
|
52
|
+
(item) =>
|
|
53
|
+
`<div class="card"><div class="card-label">${escapeHtml(item.label)}</div>` +
|
|
54
|
+
`<div class="card-value">${escapeHtml(item.value)}</div>` +
|
|
55
|
+
(item.hint ? `<div class="card-hint">${escapeHtml(item.hint)}</div>` : '') +
|
|
56
|
+
'</div>',
|
|
57
|
+
)
|
|
58
|
+
.join('');
|
|
59
|
+
|
|
60
|
+
return `<div class="cards cards-${columns} cards-${variant}">${cards}</div>`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function renderTable(block: Extract<PdfDocumentBlock, { kind: 'table' }>): string {
|
|
64
|
+
const {
|
|
65
|
+
columns,
|
|
66
|
+
rows,
|
|
67
|
+
numbered = false,
|
|
68
|
+
numberHeader = '#',
|
|
69
|
+
zebra = true,
|
|
70
|
+
compact = false,
|
|
71
|
+
emptyText = 'Nenhum registro no recorte selecionado.',
|
|
72
|
+
truncatedNote,
|
|
73
|
+
} = block;
|
|
74
|
+
|
|
75
|
+
if (rows.length === 0) {
|
|
76
|
+
return `<div class="table-empty">${escapeHtml(emptyText)}</div>`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const head =
|
|
80
|
+
(numbered ? `<th class="idx">${escapeHtml(numberHeader)}</th>` : '') +
|
|
81
|
+
columns
|
|
82
|
+
.map(
|
|
83
|
+
(column) =>
|
|
84
|
+
`<th${alignClass(column)}${column.width ? ` style="width:${escapeHtml(column.width)}"` : ''}>` +
|
|
85
|
+
`${escapeHtml(column.header)}</th>`,
|
|
86
|
+
)
|
|
87
|
+
.join('');
|
|
88
|
+
|
|
89
|
+
// `push` + `join` em vez de concatenar em laço: uma conta grande gera milhares
|
|
90
|
+
// de linhas, e a string intermediária de cada passo é o que faz o render
|
|
91
|
+
// arrastar.
|
|
92
|
+
const body: string[] = [];
|
|
93
|
+
|
|
94
|
+
rows.forEach((row, index) => {
|
|
95
|
+
const cells: string[] = [];
|
|
96
|
+
|
|
97
|
+
if (numbered) cells.push(`<td class="idx">${index + 1}</td>`);
|
|
98
|
+
|
|
99
|
+
columns.forEach((column, columnIndex) => {
|
|
100
|
+
const cell = toCell(row[columnIndex]);
|
|
101
|
+
const tone = cell.tone && cell.tone !== 'default' ? ` tone-${cell.tone}` : '';
|
|
102
|
+
const align =
|
|
103
|
+
column.align === 'right' ? ' align-right' : column.align === 'center' ? ' align-center' : '';
|
|
104
|
+
|
|
105
|
+
cells.push(
|
|
106
|
+
`<td class="${(tone + align).trim()}">${escapeHtml(cell.text)}` +
|
|
107
|
+
(cell.sub ? `<div class="cell-sub">${escapeHtml(cell.sub)}</div>` : '') +
|
|
108
|
+
'</td>',
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
body.push(`<tr>${cells.join('')}</tr>`);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const table =
|
|
116
|
+
`<table class="doc-table${zebra ? ' zebra' : ''}${compact ? ' compact' : ''}">` +
|
|
117
|
+
`<thead><tr>${head}</tr></thead><tbody>${body.join('')}</tbody></table>`;
|
|
118
|
+
|
|
119
|
+
return truncatedNote ? `${table}<div class="note">${escapeHtml(truncatedNote)}</div>` : table;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function renderBlock(block: PdfDocumentBlock): string {
|
|
123
|
+
switch (block.kind) {
|
|
124
|
+
case 'section':
|
|
125
|
+
return (
|
|
126
|
+
`<h2>${escapeHtml(block.title)}` +
|
|
127
|
+
(block.hint ? `<span class="section-hint">${escapeHtml(block.hint)}</span>` : '') +
|
|
128
|
+
'</h2>'
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
case 'cards':
|
|
132
|
+
return renderCards(block.items, block.columns ?? 4, block.variant ?? 'metric');
|
|
133
|
+
|
|
134
|
+
case 'table':
|
|
135
|
+
return renderTable(block);
|
|
136
|
+
|
|
137
|
+
case 'chart':
|
|
138
|
+
return (
|
|
139
|
+
'<div class="chart">' +
|
|
140
|
+
(block.title ? `<div class="chart-title">${escapeHtml(block.title)}</div>` : '') +
|
|
141
|
+
block.svg +
|
|
142
|
+
(block.caption ? `<div class="chart-caption">${escapeHtml(block.caption)}</div>` : '') +
|
|
143
|
+
'</div>'
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
case 'chartGrid':
|
|
147
|
+
return (
|
|
148
|
+
'<div class="chart-grid">' +
|
|
149
|
+
block.items
|
|
150
|
+
.map(
|
|
151
|
+
(item) =>
|
|
152
|
+
'<div class="chart">' +
|
|
153
|
+
(item.title ? `<div class="chart-title">${escapeHtml(item.title)}</div>` : '') +
|
|
154
|
+
item.svg +
|
|
155
|
+
(item.caption ? `<div class="chart-caption">${escapeHtml(item.caption)}</div>` : '') +
|
|
156
|
+
'</div>',
|
|
157
|
+
)
|
|
158
|
+
.join('') +
|
|
159
|
+
'</div>'
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
case 'note':
|
|
163
|
+
return `<div class="note${block.tone === 'accent' ? ' note-accent' : ''}">${escapeHtml(block.text)}</div>`;
|
|
164
|
+
|
|
165
|
+
case 'legend':
|
|
166
|
+
return `<div class="legend">${block.lines.map((l) => `<p>${escapeHtml(l)}</p>`).join('')}</div>`;
|
|
167
|
+
|
|
168
|
+
case 'pageBreak':
|
|
169
|
+
return '<div class="page-break"></div>';
|
|
170
|
+
|
|
171
|
+
default:
|
|
172
|
+
return '';
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function renderDocumentHtml(input: PdfDocumentInput, theme: PdfDocumentTheme): string {
|
|
177
|
+
const locale = input.locale ?? 'pt-BR';
|
|
178
|
+
const generatedAt = input.generatedAt ?? new Date();
|
|
179
|
+
const logo = input.logoDataUri || theme.logoUrl;
|
|
180
|
+
|
|
181
|
+
const meta = (input.meta ?? [])
|
|
182
|
+
.map(
|
|
183
|
+
(item) =>
|
|
184
|
+
`<div>${escapeHtml(item.label)}: <span class="meta-value">${escapeHtml(item.value)}</span></div>`,
|
|
185
|
+
)
|
|
186
|
+
.join('');
|
|
187
|
+
|
|
188
|
+
const body = input.blocks.map((block) => renderBlock(block)).join('\n');
|
|
189
|
+
|
|
190
|
+
return `<!DOCTYPE html>
|
|
191
|
+
<html lang="${escapeHtml(locale)}">
|
|
192
|
+
<head>
|
|
193
|
+
<meta charset="utf-8" />
|
|
194
|
+
<title>${escapeHtml(input.title)}</title>
|
|
195
|
+
<style>${buildDocumentCss(theme, input.landscape ?? false)}</style>
|
|
196
|
+
</head>
|
|
197
|
+
<body>
|
|
198
|
+
<header>
|
|
199
|
+
<div class="brand">
|
|
200
|
+
<img src="${escapeHtml(logo)}" alt="${escapeHtml(theme.companyName)}" />
|
|
201
|
+
<div class="brand-text">
|
|
202
|
+
${input.eyebrow ? `<div class="eyebrow">${escapeHtml(input.eyebrow)}</div>` : ''}
|
|
203
|
+
<h1>${escapeHtml(input.title)}</h1>
|
|
204
|
+
${input.subtitle ? `<div class="company-name">${escapeHtml(input.subtitle)}</div>` : ''}
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
<div class="meta-block">
|
|
208
|
+
<div>Gerado em: <span class="meta-value">${escapeHtml(formatGeneratedAt(generatedAt, locale))}</span></div>
|
|
209
|
+
${meta}
|
|
210
|
+
</div>
|
|
211
|
+
</header>
|
|
212
|
+
${body}
|
|
213
|
+
</body>
|
|
214
|
+
</html>`;
|
|
215
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Inject, Injectable, forwardRef } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
import { BrandAssetService } from '../../setting/brand-asset.service';
|
|
4
|
+
import { SettingService } from '../../setting/setting.service';
|
|
5
|
+
import { PdfService } from '../pdf.service';
|
|
6
|
+
import {
|
|
7
|
+
PDF_DOCUMENT_MARGIN,
|
|
8
|
+
buildAccentHeaderTemplate,
|
|
9
|
+
buildFooterTemplate,
|
|
10
|
+
} from './pdf-document.chrome';
|
|
11
|
+
import { renderDocumentHtml } from './pdf-document.renderer';
|
|
12
|
+
import {
|
|
13
|
+
PdfDocumentTheme,
|
|
14
|
+
PdfDocumentThemeOverrides,
|
|
15
|
+
resolveDocumentTheme,
|
|
16
|
+
} from './pdf-document.theme';
|
|
17
|
+
import { PdfDocumentInput } from './pdf-document.types';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Gera o documento PDF padrão do sistema: letterhead com logo, cards, tabelas,
|
|
21
|
+
* gráficos, barra de destaque no topo de toda página e paginação "X / N".
|
|
22
|
+
*
|
|
23
|
+
* Quem chama entrega blocos de dados ({@link PdfDocumentInput}); o HTML e o
|
|
24
|
+
* Chromium são responsabilidade daqui.
|
|
25
|
+
*/
|
|
26
|
+
@Injectable()
|
|
27
|
+
export class PdfDocumentService {
|
|
28
|
+
constructor(
|
|
29
|
+
@Inject(forwardRef(() => PdfService))
|
|
30
|
+
private readonly pdf: PdfService,
|
|
31
|
+
@Inject(forwardRef(() => SettingService))
|
|
32
|
+
private readonly settingService: SettingService,
|
|
33
|
+
@Inject(forwardRef(() => BrandAssetService))
|
|
34
|
+
private readonly brandAsset: BrandAssetService,
|
|
35
|
+
) {}
|
|
36
|
+
|
|
37
|
+
loadTheme(overrides?: PdfDocumentThemeOverrides): Promise<PdfDocumentTheme> {
|
|
38
|
+
return resolveDocumentTheme(this.settingService, this.brandAsset, overrides);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** HTML puro, sem Chromium — serve aos testes e a quem quiser inspecionar. */
|
|
42
|
+
async buildHtml(input: PdfDocumentInput, theme?: PdfDocumentTheme): Promise<string> {
|
|
43
|
+
return renderDocumentHtml(input, theme ?? (await this.loadTheme()));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async render(
|
|
47
|
+
input: PdfDocumentInput,
|
|
48
|
+
options: { theme?: PdfDocumentTheme; label?: string; timeoutMs?: number } = {},
|
|
49
|
+
): Promise<Buffer> {
|
|
50
|
+
const theme = options.theme ?? (await this.loadTheme());
|
|
51
|
+
const html = renderDocumentHtml(input, theme);
|
|
52
|
+
|
|
53
|
+
const generatedAt = input.generatedAt ?? new Date();
|
|
54
|
+
const footerLeft = [
|
|
55
|
+
input.footerLeft ?? 'Documento Confidencial',
|
|
56
|
+
`Gerado em ${new Intl.DateTimeFormat(input.locale ?? 'pt-BR').format(generatedAt)}`,
|
|
57
|
+
theme.companyName,
|
|
58
|
+
]
|
|
59
|
+
.filter(Boolean)
|
|
60
|
+
.join(' · ');
|
|
61
|
+
|
|
62
|
+
return this.pdf.generatePdfFromHtml(html, {
|
|
63
|
+
format: 'A4',
|
|
64
|
+
landscape: input.landscape ?? false,
|
|
65
|
+
printBackground: true,
|
|
66
|
+
margin: PDF_DOCUMENT_MARGIN,
|
|
67
|
+
displayHeaderFooter: true,
|
|
68
|
+
headerTemplate: buildAccentHeaderTemplate(theme),
|
|
69
|
+
footerTemplate: buildFooterTemplate(theme, footerLeft),
|
|
70
|
+
// O logo já vem embutido como data URI e não há fonte web: esperar por
|
|
71
|
+
// rede ociosa só adicionaria o timeout do `networkidle` ao render.
|
|
72
|
+
waitUntil: 'load',
|
|
73
|
+
timeoutMs: options.timeoutMs ?? 60_000,
|
|
74
|
+
label: options.label,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { BrandAssetService } from '../../setting/brand-asset.service';
|
|
2
|
+
import { SettingService } from '../../setting/setting.service';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tokens do documento padrão. São as mesmas settings que a proposta comercial
|
|
6
|
+
* do CRM lê, para que os dois PDFs saiam com a mesma identidade sem um copiar
|
|
7
|
+
* as cores do outro na mão.
|
|
8
|
+
*/
|
|
9
|
+
export interface PdfDocumentTheme {
|
|
10
|
+
primary: string;
|
|
11
|
+
/** Fundo suave dos cards e do cabeçalho da tabela. */
|
|
12
|
+
mutedBg: string;
|
|
13
|
+
/** Texto secundário: rótulos, legendas, rodapé. */
|
|
14
|
+
mutedFg: string;
|
|
15
|
+
/** Texto principal. */
|
|
16
|
+
ink: string;
|
|
17
|
+
/** Bordas. */
|
|
18
|
+
line: string;
|
|
19
|
+
companyName: string;
|
|
20
|
+
/** Data URI ou URL absoluta do logo do sistema. */
|
|
21
|
+
logoUrl: string;
|
|
22
|
+
fontFamily: string;
|
|
23
|
+
/** Cores categóricas dos gráficos, com a primária na frente. */
|
|
24
|
+
chartPalette: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const FALLBACK = {
|
|
28
|
+
primary: '#ff760c',
|
|
29
|
+
mutedBg: '#f4f4f5',
|
|
30
|
+
mutedFg: '#71717a',
|
|
31
|
+
ink: '#18181b',
|
|
32
|
+
line: '#e4e4e7',
|
|
33
|
+
companyName: 'HedHog',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Nenhuma fonte web: o container da API só tem `ttf-freefont`, e um `<link>`
|
|
38
|
+
* para o Google Fonts com `waitUntil: 'networkidle'` vira espera até o timeout
|
|
39
|
+
* quando o pod não tem saída para a internet.
|
|
40
|
+
*/
|
|
41
|
+
export const PDF_DOCUMENT_FONT = 'Arial, Helvetica, sans-serif';
|
|
42
|
+
|
|
43
|
+
/** Paleta print-safe: sem amarelo puro nem cinza claro, que somem no papel. */
|
|
44
|
+
const CATEGORICAL = [
|
|
45
|
+
'#2563eb',
|
|
46
|
+
'#16a34a',
|
|
47
|
+
'#9333ea',
|
|
48
|
+
'#dc2626',
|
|
49
|
+
'#0891b2',
|
|
50
|
+
'#b45309',
|
|
51
|
+
'#4f46e5',
|
|
52
|
+
'#475569',
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const text = (value: unknown): string | null => {
|
|
56
|
+
if (typeof value !== 'string') return null;
|
|
57
|
+
const trimmed = value.trim();
|
|
58
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export interface PdfDocumentThemeOverrides {
|
|
62
|
+
companyName?: string;
|
|
63
|
+
primary?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Lê as settings de marca. Qualquer falha cai nos valores de fábrica — um
|
|
68
|
+
* relatório sem cor certa ainda é um relatório; um relatório que não sai não é.
|
|
69
|
+
*/
|
|
70
|
+
export async function resolveDocumentTheme(
|
|
71
|
+
settingService: SettingService,
|
|
72
|
+
brandAsset: BrandAssetService,
|
|
73
|
+
overrides: PdfDocumentThemeOverrides = {},
|
|
74
|
+
): Promise<PdfDocumentTheme> {
|
|
75
|
+
let values: Record<string, any> = {};
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
values = await settingService.getSettingValues([
|
|
79
|
+
'theme-primary-light',
|
|
80
|
+
'theme-muted-light',
|
|
81
|
+
'theme-muted-foreground-light',
|
|
82
|
+
'theme-secondary-foreground-light',
|
|
83
|
+
'system-name',
|
|
84
|
+
'image-url',
|
|
85
|
+
'icon-url',
|
|
86
|
+
]);
|
|
87
|
+
} catch {
|
|
88
|
+
values = {};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const primary = overrides.primary ?? text(values['theme-primary-light']) ?? FALLBACK.primary;
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
primary,
|
|
95
|
+
mutedBg: text(values['theme-muted-light']) ?? FALLBACK.mutedBg,
|
|
96
|
+
mutedFg: text(values['theme-muted-foreground-light']) ?? FALLBACK.mutedFg,
|
|
97
|
+
ink: text(values['theme-secondary-foreground-light']) ?? FALLBACK.ink,
|
|
98
|
+
line: FALLBACK.line,
|
|
99
|
+
companyName: overrides.companyName ?? text(values['system-name']) ?? FALLBACK.companyName,
|
|
100
|
+
logoUrl: await resolveSystemLogo(brandAsset, values, primary),
|
|
101
|
+
fontFamily: PDF_DOCUMENT_FONT,
|
|
102
|
+
chartPalette: [primary, ...CATEGORICAL],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* O Chromium renderiza sem sessão e, dentro do cluster, pode não ter rota para
|
|
108
|
+
* o host público — por isso a imagem é baixada e embutida como data URI, com a
|
|
109
|
+
* URL absoluta como segundo recurso e um wordmark inline como último.
|
|
110
|
+
*/
|
|
111
|
+
async function resolveSystemLogo(
|
|
112
|
+
brandAsset: BrandAssetService,
|
|
113
|
+
values: Record<string, any>,
|
|
114
|
+
primary: string,
|
|
115
|
+
): Promise<string> {
|
|
116
|
+
const configured = text(values['image-url']) ?? text(values['icon-url']);
|
|
117
|
+
|
|
118
|
+
if (configured) {
|
|
119
|
+
try {
|
|
120
|
+
const absolute = await brandAsset.resolveAssetUrl(configured);
|
|
121
|
+
if (absolute) {
|
|
122
|
+
const dataUri = await brandAsset.fetchAsDataUri(absolute);
|
|
123
|
+
if (dataUri) return dataUri;
|
|
124
|
+
return absolute;
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
// Cai no wordmark abaixo.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return inlineWordmark(primary);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function inlineWordmark(primary: string): string {
|
|
135
|
+
const svg = encodeURIComponent(
|
|
136
|
+
[
|
|
137
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="240" height="72" viewBox="0 0 240 72">',
|
|
138
|
+
'<rect width="240" height="72" rx="18" fill="#0f172a"/>',
|
|
139
|
+
`<circle cx="38" cy="36" r="18" fill="${primary}"/>`,
|
|
140
|
+
'<text x="72" y="43" fill="#ffffff" font-size="26" font-family="Arial, sans-serif" font-weight="700">HedHog</text>',
|
|
141
|
+
'</svg>',
|
|
142
|
+
].join(''),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
return `data:image/svg+xml;charset=UTF-8,${svg}`;
|
|
146
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Modelo de um documento PDF padrão: o chamador descreve **dados**, nunca HTML.
|
|
3
|
+
*
|
|
4
|
+
* É o que sustenta a regra de escape do renderer — se um serviço pudesse passar
|
|
5
|
+
* markup, o nome de um aluno com `<` viraria tag dentro de um Chromium headless.
|
|
6
|
+
* A única exceção é o SVG dos gráficos, que carrega o tipo {@link SafeSvg} e só
|
|
7
|
+
* pode ser produzido pelos desenhistas do próprio kit.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** SVG montado pelos helpers de gráfico do kit — a única string injetada sem escape. */
|
|
11
|
+
export type SafeSvg = string & { readonly __safeSvg: unique symbol };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Marca um SVG como seguro. Só os desenhistas de `./charts` devem chamar isto:
|
|
15
|
+
* quem passar conteúdo de usuário aqui reabre a porta que o modelo de blocos fecha.
|
|
16
|
+
*/
|
|
17
|
+
export const asSafeSvg = (svg: string): SafeSvg => svg as SafeSvg;
|
|
18
|
+
|
|
19
|
+
export interface PdfDocumentMeta {
|
|
20
|
+
label: string;
|
|
21
|
+
value: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PdfDocumentCard {
|
|
25
|
+
label: string;
|
|
26
|
+
value: string;
|
|
27
|
+
/** Linha pequena abaixo do valor, para a unidade ou a ressalva. */
|
|
28
|
+
hint?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PdfDocumentTableColumn {
|
|
32
|
+
header: string;
|
|
33
|
+
align?: 'left' | 'center' | 'right';
|
|
34
|
+
/**
|
|
35
|
+
* Largura CSS (ex.: '18%'). Sem ela o Chromium reparte pelo conteúdo e a
|
|
36
|
+
* tabela muda de forma a cada página.
|
|
37
|
+
*/
|
|
38
|
+
width?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PdfDocumentTableCell {
|
|
42
|
+
text: string;
|
|
43
|
+
/** Segunda linha menor: o e-mail sob o nome, o "Em risco" sob o status. */
|
|
44
|
+
sub?: string;
|
|
45
|
+
tone?: 'default' | 'muted' | 'bold' | 'accent' | 'warn';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type PdfDocumentRow = Array<string | number | null | undefined | PdfDocumentTableCell>;
|
|
49
|
+
|
|
50
|
+
export type PdfDocumentBlock =
|
|
51
|
+
| { kind: 'section'; title: string; hint?: string }
|
|
52
|
+
| {
|
|
53
|
+
kind: 'cards';
|
|
54
|
+
items: PdfDocumentCard[];
|
|
55
|
+
/** Padrão: 4. */
|
|
56
|
+
columns?: 2 | 3 | 4 | 5;
|
|
57
|
+
/** 'metric' destaca o valor (indicador); 'filter' é o carimbo do recorte. */
|
|
58
|
+
variant?: 'metric' | 'filter';
|
|
59
|
+
}
|
|
60
|
+
| {
|
|
61
|
+
kind: 'table';
|
|
62
|
+
columns: PdfDocumentTableColumn[];
|
|
63
|
+
rows: PdfDocumentRow[];
|
|
64
|
+
/** Coluna "#" à esquerda, 1..N contínua entre páginas. */
|
|
65
|
+
numbered?: boolean;
|
|
66
|
+
numberHeader?: string;
|
|
67
|
+
zebra?: boolean;
|
|
68
|
+
/** Fonte menor, para tabela com muitas colunas. */
|
|
69
|
+
compact?: boolean;
|
|
70
|
+
emptyText?: string;
|
|
71
|
+
/** Aviso de corte quando a origem truncou as linhas. */
|
|
72
|
+
truncatedNote?: string;
|
|
73
|
+
}
|
|
74
|
+
| { kind: 'chart'; title?: string; svg: SafeSvg; caption?: string }
|
|
75
|
+
| { kind: 'chartGrid'; items: Array<{ title?: string; svg: SafeSvg; caption?: string }> }
|
|
76
|
+
| { kind: 'note'; text: string; tone?: 'muted' | 'accent' }
|
|
77
|
+
| { kind: 'legend'; lines: string[] }
|
|
78
|
+
| { kind: 'pageBreak' };
|
|
79
|
+
|
|
80
|
+
export interface PdfDocumentInput {
|
|
81
|
+
/** O `h1` do letterhead. */
|
|
82
|
+
title: string;
|
|
83
|
+
/** Sobretítulo laranja, em caixa alta (ex.: "Relatório de Alunos"). */
|
|
84
|
+
eyebrow?: string;
|
|
85
|
+
/** Linha cinza abaixo do título — normalmente o nome da empresa. */
|
|
86
|
+
subtitle?: string;
|
|
87
|
+
/** Bloco alinhado à direita do letterhead. */
|
|
88
|
+
meta?: PdfDocumentMeta[];
|
|
89
|
+
blocks: PdfDocumentBlock[];
|
|
90
|
+
/** Data URI do logo; sem ele o kit usa o logo do sistema. */
|
|
91
|
+
logoDataUri?: string | null;
|
|
92
|
+
generatedAt?: Date;
|
|
93
|
+
locale?: string;
|
|
94
|
+
landscape?: boolean;
|
|
95
|
+
/** Padrão: 'Documento Confidencial'. */
|
|
96
|
+
footerLeft?: string;
|
|
97
|
+
}
|
package/src/pdf/pdf.module.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { forwardRef, Module } from '@nestjs/common';
|
|
|
2
2
|
import { ConfigModule } from '@nestjs/config';
|
|
3
3
|
|
|
4
4
|
import { SettingModule } from '../setting/setting.module';
|
|
5
|
+
import { PdfDocumentService } from './document/pdf-document.service';
|
|
5
6
|
import { PdfController } from './pdf.controller';
|
|
6
7
|
import { PdfService } from './pdf.service';
|
|
7
8
|
|
|
@@ -10,8 +11,8 @@ import { PdfService } from './pdf.service';
|
|
|
10
11
|
ConfigModule,
|
|
11
12
|
forwardRef(() => SettingModule),
|
|
12
13
|
],
|
|
13
|
-
providers: [PdfService],
|
|
14
|
-
exports: [PdfService],
|
|
14
|
+
providers: [PdfService, PdfDocumentService],
|
|
15
|
+
exports: [PdfService, PdfDocumentService],
|
|
15
16
|
controllers: [PdfController],
|
|
16
17
|
})
|
|
17
18
|
export class PdfModule {}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { ServiceUnavailableException } from '@nestjs/common';
|
|
2
|
+
import { afterEach, describe, expect, it, jest } from '@jest/globals';
|
|
3
|
+
|
|
4
|
+
import { PdfService, buildPdfPageOptions } from './pdf.service';
|
|
5
|
+
|
|
6
|
+
/** O serviço só toca em SettingService/BrandAssetService fora do caminho de render. */
|
|
7
|
+
const makeService = (env: Record<string, string> = {}): PdfService =>
|
|
8
|
+
new PdfService({} as any, {} as any, {
|
|
9
|
+
get: (key: string) => env[key],
|
|
10
|
+
} as any);
|
|
11
|
+
|
|
12
|
+
describe('buildPdfPageOptions', () => {
|
|
13
|
+
// O contrato que protege ceia, certificado e material de aula: quem não passa
|
|
14
|
+
// nenhuma das opções novas tem que receber o mesmo objeto de antes delas
|
|
15
|
+
// existirem — a simples presença de `displayHeaderFooter` muda o resultado.
|
|
16
|
+
it('não acrescenta chave nenhuma quando só as opções antigas vêm', () => {
|
|
17
|
+
expect(buildPdfPageOptions({})).toEqual({
|
|
18
|
+
format: 'A4',
|
|
19
|
+
printBackground: true,
|
|
20
|
+
landscape: false,
|
|
21
|
+
margin: { top: '0', right: '0', bottom: '0', left: '0' },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
expect(Object.keys(buildPdfPageOptions({ format: 'A4' })).sort()).toEqual([
|
|
25
|
+
'format',
|
|
26
|
+
'landscape',
|
|
27
|
+
'margin',
|
|
28
|
+
'printBackground',
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('troca format/landscape por width/height quando os dois vêm', () => {
|
|
33
|
+
const options = buildPdfPageOptions({ width: '1600px', height: '1131px' });
|
|
34
|
+
|
|
35
|
+
expect(options).toEqual({
|
|
36
|
+
width: '1600px',
|
|
37
|
+
height: '1131px',
|
|
38
|
+
printBackground: true,
|
|
39
|
+
margin: { top: '0', right: '0', bottom: '0', left: '0' },
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Com um template só, o Chromium volta a injetar o dele (URL e data) no outro.
|
|
44
|
+
it('preenche os dois templates quando o cabeçalho/rodapé é ligado', () => {
|
|
45
|
+
const options = buildPdfPageOptions({
|
|
46
|
+
displayHeaderFooter: true,
|
|
47
|
+
headerTemplate: '<div class="bar"></div>',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(options.displayHeaderFooter).toBe(true);
|
|
51
|
+
expect(options.headerTemplate).toBe('<div class="bar"></div>');
|
|
52
|
+
expect(options.footerTemplate).toBe('<span></span>');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('ignora os templates enquanto displayHeaderFooter não for ligado', () => {
|
|
56
|
+
const options = buildPdfPageOptions({ footerTemplate: '<span></span>' });
|
|
57
|
+
|
|
58
|
+
expect(options).not.toHaveProperty('displayHeaderFooter');
|
|
59
|
+
expect(options).not.toHaveProperty('footerTemplate');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('só repassa scale e pageRanges quando vêm', () => {
|
|
63
|
+
expect(buildPdfPageOptions({ scale: 0.9, pageRanges: '1-2' })).toMatchObject({
|
|
64
|
+
scale: 0.9,
|
|
65
|
+
pageRanges: '1-2',
|
|
66
|
+
});
|
|
67
|
+
expect(buildPdfPageOptions({})).not.toHaveProperty('scale');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('fila de renderização', () => {
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
jest.useRealTimers();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('segura o render acima do teto até uma vaga ser devolvida', async () => {
|
|
77
|
+
const service = makeService({ PDF_MAX_CONCURRENCY: '1' });
|
|
78
|
+
const releaseFirst = await (service as any).acquireRenderSlot('primeiro');
|
|
79
|
+
|
|
80
|
+
let liberado = false;
|
|
81
|
+
const second = (service as any)
|
|
82
|
+
.acquireRenderSlot('segundo')
|
|
83
|
+
.then((release: () => void) => {
|
|
84
|
+
liberado = true;
|
|
85
|
+
return release;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await new Promise((resolve) => process.nextTick(resolve));
|
|
89
|
+
expect(liberado).toBe(false);
|
|
90
|
+
|
|
91
|
+
releaseFirst();
|
|
92
|
+
const releaseSecond = await second;
|
|
93
|
+
expect(liberado).toBe(true);
|
|
94
|
+
releaseSecond();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('responde 503 quando a espera na fila estoura', async () => {
|
|
98
|
+
jest.useFakeTimers();
|
|
99
|
+
const service = makeService({ PDF_MAX_CONCURRENCY: '1', PDF_QUEUE_WAIT_MS: '50' });
|
|
100
|
+
const release = await (service as any).acquireRenderSlot('primeiro');
|
|
101
|
+
|
|
102
|
+
const queued = (service as any).acquireRenderSlot('segundo');
|
|
103
|
+
const rejeicao = expect(queued).rejects.toBeInstanceOf(ServiceUnavailableException);
|
|
104
|
+
|
|
105
|
+
jest.advanceTimersByTime(50);
|
|
106
|
+
await rejeicao;
|
|
107
|
+
|
|
108
|
+
release();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Liberar duas vezes (ex.: `finally` somado a um caminho de erro) não pode
|
|
112
|
+
// inflar a capacidade e deixar dois Chromium subirem juntos.
|
|
113
|
+
it('ignora a segunda liberação do mesmo render', async () => {
|
|
114
|
+
const service = makeService({ PDF_MAX_CONCURRENCY: '1' });
|
|
115
|
+
const release = await (service as any).acquireRenderSlot('primeiro');
|
|
116
|
+
release();
|
|
117
|
+
release();
|
|
118
|
+
|
|
119
|
+
const releaseSecond = await (service as any).acquireRenderSlot('segundo');
|
|
120
|
+
|
|
121
|
+
let terceiroEntrou = false;
|
|
122
|
+
void (service as any).acquireRenderSlot('terceiro').then(() => {
|
|
123
|
+
terceiroEntrou = true;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await new Promise((resolve) => process.nextTick(resolve));
|
|
127
|
+
expect(terceiroEntrou).toBe(false);
|
|
128
|
+
|
|
129
|
+
releaseSecond();
|
|
130
|
+
});
|
|
131
|
+
});
|