@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,157 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals';
|
|
2
|
+
|
|
3
|
+
import { PdfDocumentTheme } from '../pdf-document.theme';
|
|
4
|
+
import {
|
|
5
|
+
areaChartSvg,
|
|
6
|
+
barChartSvg,
|
|
7
|
+
donutChartSvg,
|
|
8
|
+
gaugeChartSvg,
|
|
9
|
+
horizontalBarChartSvg,
|
|
10
|
+
lineChartSvg,
|
|
11
|
+
radarChartSvg,
|
|
12
|
+
} from './index';
|
|
13
|
+
|
|
14
|
+
const theme: PdfDocumentTheme = {
|
|
15
|
+
primary: '#ff760c',
|
|
16
|
+
mutedBg: '#f4f4f5',
|
|
17
|
+
mutedFg: '#71717a',
|
|
18
|
+
ink: '#18181b',
|
|
19
|
+
line: '#e4e4e7',
|
|
20
|
+
companyName: 'HedHog',
|
|
21
|
+
logoUrl: '',
|
|
22
|
+
fontFamily: 'Arial, Helvetica, sans-serif',
|
|
23
|
+
chartPalette: ['#ff760c', '#2563eb', '#16a34a'],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const meses = ['Jan', 'Fev', 'Mar', 'Abr'];
|
|
27
|
+
|
|
28
|
+
describe('desenhistas de gráfico', () => {
|
|
29
|
+
it('produzem SVG válido com viewBox', () => {
|
|
30
|
+
const svgs = [
|
|
31
|
+
barChartSvg({ theme, categories: meses, series: [{ name: 'Matrículas', values: [3, 8, 5, 9] }] }),
|
|
32
|
+
horizontalBarChartSvg({
|
|
33
|
+
theme,
|
|
34
|
+
categories: ['Curso A', 'Curso B'],
|
|
35
|
+
series: [{ name: 'Conclusão', values: [80, 45] }],
|
|
36
|
+
}),
|
|
37
|
+
lineChartSvg({ theme, categories: meses, series: [{ name: 'Frequência', values: [70, 75, 80, 90] }] }),
|
|
38
|
+
donutChartSvg({
|
|
39
|
+
theme,
|
|
40
|
+
slices: [
|
|
41
|
+
{ label: 'Ativos', value: 30 },
|
|
42
|
+
{ label: 'Em risco', value: 10 },
|
|
43
|
+
],
|
|
44
|
+
}),
|
|
45
|
+
radarChartSvg({
|
|
46
|
+
theme,
|
|
47
|
+
axes: ['Conteúdo', 'Didática', 'Ritmo'],
|
|
48
|
+
series: [{ name: 'Média', values: [4.5, 3.8, 4.1] }],
|
|
49
|
+
max: 5,
|
|
50
|
+
}),
|
|
51
|
+
gaugeChartSvg({ theme, items: [{ label: 'Curso A', pct: 62 }] }),
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
for (const svg of svgs) {
|
|
55
|
+
expect(svg).toMatch(/^<svg viewBox="0 0 \d+ \d+"/);
|
|
56
|
+
expect(svg.endsWith('</svg>')).toBe(true);
|
|
57
|
+
// Tag desbalanceada só apareceria no papel, e tarde demais.
|
|
58
|
+
expect(svg.split('<').length).toBe(svg.split('>').length);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Rótulo de curso é dado de usuário indo para dentro de um Chromium headless.
|
|
63
|
+
it('escapam o texto que vem dos dados', () => {
|
|
64
|
+
const svg = horizontalBarChartSvg({
|
|
65
|
+
theme,
|
|
66
|
+
categories: ['<script>alert(1)</script>'],
|
|
67
|
+
series: [{ name: 'x', values: [10] }],
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(svg).not.toContain('<script>');
|
|
71
|
+
expect(svg).toContain('<script>');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('caem no aviso de vazio em vez de desenhar uma moldura sem nada', () => {
|
|
75
|
+
const semDados = barChartSvg({ theme, categories: [], series: [] });
|
|
76
|
+
const soNulos = lineChartSvg({ theme, categories: meses, series: [{ name: 'x', values: [null, null, null, null] }] });
|
|
77
|
+
const roscaZerada = donutChartSvg({ theme, slices: [{ label: 'Nada', value: 0 }] });
|
|
78
|
+
|
|
79
|
+
for (const svg of [semDados, soNulos, roscaZerada]) {
|
|
80
|
+
expect(svg).toContain('Sem dados no período');
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('respeitam o texto de vazio escolhido pelo chamador', () => {
|
|
85
|
+
expect(
|
|
86
|
+
barChartSvg({ theme, categories: [], series: [], emptyText: 'Nenhuma turma no recorte' }),
|
|
87
|
+
).toContain('Nenhuma turma no recorte');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Buraco na série é ausência de dado, não zero: emendar a linha inventaria
|
|
91
|
+
// uma queda que não aconteceu.
|
|
92
|
+
it('interrompem a linha no buraco da série', () => {
|
|
93
|
+
const comBuraco = lineChartSvg({
|
|
94
|
+
theme,
|
|
95
|
+
categories: meses,
|
|
96
|
+
series: [{ name: 'Nota', values: [4, null, 3, 5] }],
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect((comBuraco.match(/<path d="M/g) ?? []).length).toBeGreaterThan(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('desenham a linha de meta quando pedida', () => {
|
|
103
|
+
const svg = areaChartSvg({
|
|
104
|
+
theme,
|
|
105
|
+
categories: meses,
|
|
106
|
+
series: [{ name: 'Frequência', values: [70, 75, 80, 90] }],
|
|
107
|
+
referenceLine: { value: 80, label: 'Meta 80%' },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(svg).toContain('stroke-dasharray="4 3"');
|
|
111
|
+
expect(svg).toContain('Meta 80%');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('a área preenche sob a linha e a linha simples não', () => {
|
|
115
|
+
const base = { theme, categories: meses, series: [{ name: 'x', values: [1, 2, 3, 4] }] };
|
|
116
|
+
|
|
117
|
+
expect(areaChartSvg(base)).toContain('rgba(255,118,12,0.18)');
|
|
118
|
+
expect(lineChartSvg(base)).not.toContain('rgba(255,118,12,0.18)');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('a rosca mostra o percentual de cada fatia', () => {
|
|
122
|
+
const svg = donutChartSvg({
|
|
123
|
+
theme,
|
|
124
|
+
slices: [
|
|
125
|
+
{ label: 'Ativos', value: 75 },
|
|
126
|
+
{ label: 'Inativos', value: 25 },
|
|
127
|
+
],
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(svg).toContain('75%');
|
|
131
|
+
expect(svg).toContain('25%');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// A legenda era dimensionada por subtração e o epsilon de ponto flutuante
|
|
135
|
+
// cortava o rótulo que cabia exatamente: "Em risco" saía "Em ris…".
|
|
136
|
+
it('a legenda não corta o rótulo que cabe', () => {
|
|
137
|
+
const svg = donutChartSvg({
|
|
138
|
+
theme,
|
|
139
|
+
width: 330,
|
|
140
|
+
height: 200,
|
|
141
|
+
slices: [
|
|
142
|
+
{ label: 'Ativos', value: 318 },
|
|
143
|
+
{ label: 'Em risco', value: 87 },
|
|
144
|
+
{ label: 'Inativos', value: 16 },
|
|
145
|
+
],
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(svg).toContain('Em risco');
|
|
149
|
+
expect(svg).not.toContain('…');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('o radar exige três eixos para não virar um triângulo degenerado', () => {
|
|
153
|
+
expect(
|
|
154
|
+
radarChartSvg({ theme, axes: ['A', 'B'], series: [{ name: 'x', values: [1, 2] }] }),
|
|
155
|
+
).toContain('Sem dados no período');
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from './charts';
|
|
2
|
+
export * from './pdf-document.chrome';
|
|
3
|
+
export * from './pdf-document.css';
|
|
4
|
+
export * from './pdf-document.renderer';
|
|
5
|
+
export * from './pdf-document.service';
|
|
6
|
+
export * from './pdf-document.theme';
|
|
7
|
+
export * from './pdf-document.types';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { escapeHtml } from './pdf-document.renderer';
|
|
2
|
+
import { PdfDocumentTheme } from './pdf-document.theme';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Cabeçalho e rodapé nativos do Chromium — a barra laranja que repete em toda
|
|
6
|
+
* página e o "3 / 15" que só o renderizador sabe calcular.
|
|
7
|
+
*
|
|
8
|
+
* Eles são mini-documentos, não parte da página: começam em `font-size: 0`,
|
|
9
|
+
* precisam do próprio `print-color-adjust` para manter fundo, e só
|
|
10
|
+
* `<span class="pageNumber">` e `<span class="totalPages">` são substituídos.
|
|
11
|
+
* As margens abaixo reservam o espaço deles — encolher `top`/`bottom` faz o
|
|
12
|
+
* conteúdo imprimir por cima.
|
|
13
|
+
*/
|
|
14
|
+
export const PDF_DOCUMENT_MARGIN = {
|
|
15
|
+
top: '72px',
|
|
16
|
+
right: '56px',
|
|
17
|
+
bottom: '56px',
|
|
18
|
+
left: '56px',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function buildAccentHeaderTemplate(theme: PdfDocumentTheme): string {
|
|
22
|
+
return (
|
|
23
|
+
'<style>*{margin:0;padding:0;box-sizing:border-box;}html,body{margin:0;padding:0;}</style>' +
|
|
24
|
+
'<div style="position:absolute;top:0;left:0;right:0;width:100%;height:8px;' +
|
|
25
|
+
`background:${theme.primary};-webkit-print-color-adjust:exact;print-color-adjust:exact;"></div>`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildFooterTemplate(theme: PdfDocumentTheme, left: string): string {
|
|
30
|
+
return (
|
|
31
|
+
'<div style="width:100%;padding:0 56px;display:flex;justify-content:space-between;' +
|
|
32
|
+
`align-items:center;font-family:${theme.fontFamily};font-size:8px;color:${theme.mutedFg};` +
|
|
33
|
+
'-webkit-print-color-adjust:exact;print-color-adjust:exact;">' +
|
|
34
|
+
`<span>${escapeHtml(left)}</span>` +
|
|
35
|
+
'<span><span class="pageNumber"></span> / <span class="totalPages"></span></span>' +
|
|
36
|
+
'</div>'
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { PdfDocumentTheme } from './pdf-document.theme';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* O CSS do documento padrão — o mesmo acabamento da proposta comercial do CRM
|
|
5
|
+
* (`libraries/crm/src/proposal/proposal.service.ts`), com três acréscimos que
|
|
6
|
+
* a proposta não precisava e um relatório precisa:
|
|
7
|
+
*
|
|
8
|
+
* 1. `thead { display: table-header-group }` — sem isso a tabela perde o
|
|
9
|
+
* cabeçalho a partir da segunda página, e um relatório de 15 páginas fica
|
|
10
|
+
* ilegível da página 2 em diante.
|
|
11
|
+
* 2. `tbody tr { break-inside: avoid }` — linha não parte no meio.
|
|
12
|
+
* 3. Nada de wrapper com `break-inside: avoid`, `overflow` ou borda arredondada
|
|
13
|
+
* em volta da tabela: foi o que fez a folha de impressão da tela sair com
|
|
14
|
+
* uma primeira página vazia e conteúdo cortado.
|
|
15
|
+
*
|
|
16
|
+
* A barra laranja do topo e o "X / N" do rodapé **não estão aqui** — são
|
|
17
|
+
* templates do Chromium (ver ./pdf-document.chrome.ts), porque só eles repetem
|
|
18
|
+
* em toda página e só eles sabem o total de páginas.
|
|
19
|
+
*/
|
|
20
|
+
export function buildDocumentCss(theme: PdfDocumentTheme, landscape = false): string {
|
|
21
|
+
const { primary, mutedBg, mutedFg, ink, line, fontFamily } = theme;
|
|
22
|
+
|
|
23
|
+
return `
|
|
24
|
+
@page {
|
|
25
|
+
size: A4${landscape ? ' landscape' : ''};
|
|
26
|
+
margin: 72px 56px 56px 56px;
|
|
27
|
+
}
|
|
28
|
+
* { box-sizing: border-box; }
|
|
29
|
+
html, body {
|
|
30
|
+
-webkit-print-color-adjust: exact;
|
|
31
|
+
print-color-adjust: exact;
|
|
32
|
+
}
|
|
33
|
+
body {
|
|
34
|
+
color: ${ink};
|
|
35
|
+
font-family: ${fontFamily};
|
|
36
|
+
font-size: 11.5px;
|
|
37
|
+
line-height: 1.55;
|
|
38
|
+
margin: 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* ── Letterhead ── */
|
|
42
|
+
header {
|
|
43
|
+
display: flex;
|
|
44
|
+
justify-content: space-between;
|
|
45
|
+
align-items: flex-start;
|
|
46
|
+
gap: 24px;
|
|
47
|
+
margin-bottom: 24px;
|
|
48
|
+
padding-bottom: 20px;
|
|
49
|
+
border-bottom: 1px solid ${line};
|
|
50
|
+
}
|
|
51
|
+
.brand { display: flex; align-items: center; gap: 14px; }
|
|
52
|
+
.brand img { height: 44px; max-width: 150px; object-fit: contain; }
|
|
53
|
+
.brand-text .eyebrow {
|
|
54
|
+
color: ${primary};
|
|
55
|
+
font-size: 9px;
|
|
56
|
+
font-weight: 700;
|
|
57
|
+
letter-spacing: 0.14em;
|
|
58
|
+
text-transform: uppercase;
|
|
59
|
+
margin-bottom: 3px;
|
|
60
|
+
}
|
|
61
|
+
.brand-text h1 {
|
|
62
|
+
font-size: 20px;
|
|
63
|
+
font-weight: 700;
|
|
64
|
+
color: ${ink};
|
|
65
|
+
margin: 0 0 2px;
|
|
66
|
+
line-height: 1.2;
|
|
67
|
+
}
|
|
68
|
+
.brand-text .company-name { font-size: 11px; color: ${mutedFg}; }
|
|
69
|
+
.meta-block {
|
|
70
|
+
text-align: right;
|
|
71
|
+
font-size: 11px;
|
|
72
|
+
color: ${mutedFg};
|
|
73
|
+
line-height: 1.7;
|
|
74
|
+
white-space: nowrap;
|
|
75
|
+
}
|
|
76
|
+
.meta-block .meta-value { font-weight: 600; color: ${ink}; }
|
|
77
|
+
|
|
78
|
+
/* ── Cards ── */
|
|
79
|
+
.cards {
|
|
80
|
+
display: grid;
|
|
81
|
+
gap: 12px;
|
|
82
|
+
margin-bottom: 18px;
|
|
83
|
+
break-inside: avoid;
|
|
84
|
+
}
|
|
85
|
+
.cards-2 { grid-template-columns: repeat(2, 1fr); }
|
|
86
|
+
.cards-3 { grid-template-columns: repeat(3, 1fr); }
|
|
87
|
+
.cards-4 { grid-template-columns: repeat(4, 1fr); }
|
|
88
|
+
.cards-5 { grid-template-columns: repeat(5, 1fr); }
|
|
89
|
+
.card {
|
|
90
|
+
background: ${mutedBg};
|
|
91
|
+
border: 1px solid ${line};
|
|
92
|
+
border-top: 2px solid ${primary};
|
|
93
|
+
border-radius: 10px;
|
|
94
|
+
padding: 10px 12px;
|
|
95
|
+
}
|
|
96
|
+
.card .card-label {
|
|
97
|
+
font-size: 8.5px;
|
|
98
|
+
font-weight: 700;
|
|
99
|
+
letter-spacing: 0.12em;
|
|
100
|
+
text-transform: uppercase;
|
|
101
|
+
color: ${mutedFg};
|
|
102
|
+
margin-bottom: 4px;
|
|
103
|
+
}
|
|
104
|
+
.card .card-value { font-size: 12px; font-weight: 600; color: ${ink}; }
|
|
105
|
+
.cards-metric .card .card-value { font-size: 17px; font-weight: 700; }
|
|
106
|
+
.card .card-hint { font-size: 9px; color: ${mutedFg}; margin-top: 2px; }
|
|
107
|
+
|
|
108
|
+
/* ── Seções ── */
|
|
109
|
+
h2 {
|
|
110
|
+
font-size: 12.5px;
|
|
111
|
+
font-weight: 700;
|
|
112
|
+
color: ${ink};
|
|
113
|
+
margin: 22px 0 10px;
|
|
114
|
+
padding-bottom: 6px;
|
|
115
|
+
border-bottom: 1px solid ${line};
|
|
116
|
+
text-transform: uppercase;
|
|
117
|
+
letter-spacing: 0.06em;
|
|
118
|
+
break-after: avoid;
|
|
119
|
+
}
|
|
120
|
+
h2 .section-hint {
|
|
121
|
+
float: right;
|
|
122
|
+
font-size: 9.5px;
|
|
123
|
+
font-weight: 400;
|
|
124
|
+
letter-spacing: 0;
|
|
125
|
+
text-transform: none;
|
|
126
|
+
color: ${mutedFg};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* ── Tabela ── */
|
|
130
|
+
.doc-table {
|
|
131
|
+
border-collapse: collapse;
|
|
132
|
+
width: 100%;
|
|
133
|
+
font-size: 10.5px;
|
|
134
|
+
}
|
|
135
|
+
.doc-table.compact { font-size: 9px; }
|
|
136
|
+
.doc-table thead { display: table-header-group; }
|
|
137
|
+
.doc-table thead tr { border-bottom: 1px solid ${line}; }
|
|
138
|
+
.doc-table th {
|
|
139
|
+
background: ${mutedBg};
|
|
140
|
+
color: ${mutedFg};
|
|
141
|
+
font-size: 9px;
|
|
142
|
+
font-weight: 700;
|
|
143
|
+
letter-spacing: 0.08em;
|
|
144
|
+
text-transform: uppercase;
|
|
145
|
+
padding: 7px 8px;
|
|
146
|
+
text-align: left;
|
|
147
|
+
vertical-align: middle;
|
|
148
|
+
}
|
|
149
|
+
.doc-table td {
|
|
150
|
+
border-bottom: 1px solid ${line};
|
|
151
|
+
padding: 6px 8px;
|
|
152
|
+
text-align: left;
|
|
153
|
+
vertical-align: top;
|
|
154
|
+
color: ${ink};
|
|
155
|
+
}
|
|
156
|
+
/*
|
|
157
|
+
* Os tons precisam vencer o color da propria celula: ".doc-table td" tem
|
|
158
|
+
* especificidade 0-1-1 e ".tone-accent" sozinho tem 0-1-0, entao a cor do
|
|
159
|
+
* tom era descartada em toda tabela do sistema — so o font-weight passava,
|
|
160
|
+
* porque a regra da celula nao mexe nele. Quem marca um valor como "warn"
|
|
161
|
+
* quer que ele salte aos olhos, nao que fique so em negrito igual ao resto.
|
|
162
|
+
*/
|
|
163
|
+
.doc-table td.tone-muted { color: ${mutedFg}; }
|
|
164
|
+
.doc-table td.tone-accent { color: ${primary}; }
|
|
165
|
+
.doc-table td.tone-warn { color: #b45309; }
|
|
166
|
+
.doc-table tbody tr { break-inside: avoid; }
|
|
167
|
+
.doc-table tbody tr:last-child td { border-bottom: none; }
|
|
168
|
+
.doc-table.zebra tbody tr:nth-child(even) td { background: ${mutedBg}; }
|
|
169
|
+
.doc-table .align-right { text-align: right; }
|
|
170
|
+
.doc-table .align-center { text-align: center; }
|
|
171
|
+
/* Valor numérico não quebra: "1h 00min" partido em duas linhas dobra a
|
|
172
|
+
altura da linha e desalinha a leitura de cima a baixo. O cabeçalho pode
|
|
173
|
+
quebrar à vontade — é o que mantém "On demand" numa coluna estreita. */
|
|
174
|
+
.doc-table td.align-right,
|
|
175
|
+
.doc-table td.align-center { white-space: nowrap; }
|
|
176
|
+
/* A coluna "#" é numerada no servidor: contador de CSS reiniciaria a cada
|
|
177
|
+
repetição do <thead>. */
|
|
178
|
+
.doc-table .idx {
|
|
179
|
+
width: 30px;
|
|
180
|
+
color: ${mutedFg};
|
|
181
|
+
text-align: right;
|
|
182
|
+
font-variant-numeric: tabular-nums;
|
|
183
|
+
}
|
|
184
|
+
.cell-sub { font-size: 8.5px; color: ${mutedFg}; margin-top: 1px; }
|
|
185
|
+
.tone-muted { color: ${mutedFg}; }
|
|
186
|
+
.tone-bold { font-weight: 700; }
|
|
187
|
+
.tone-accent { color: ${primary}; font-weight: 600; }
|
|
188
|
+
.tone-warn { color: #b45309; font-weight: 600; }
|
|
189
|
+
.table-empty {
|
|
190
|
+
border: 1px dashed ${line};
|
|
191
|
+
border-radius: 10px;
|
|
192
|
+
padding: 18px;
|
|
193
|
+
text-align: center;
|
|
194
|
+
font-size: 10.5px;
|
|
195
|
+
color: ${mutedFg};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/* ── Gráficos ── */
|
|
199
|
+
.chart { margin: 4px 0 18px; break-inside: avoid; }
|
|
200
|
+
.chart-title {
|
|
201
|
+
font-size: 10.5px;
|
|
202
|
+
font-weight: 700;
|
|
203
|
+
color: ${ink};
|
|
204
|
+
margin-bottom: 6px;
|
|
205
|
+
}
|
|
206
|
+
.chart svg { width: 100%; height: auto; }
|
|
207
|
+
.chart-caption { font-size: 9px; color: ${mutedFg}; margin-top: 4px; }
|
|
208
|
+
.chart-grid {
|
|
209
|
+
display: grid;
|
|
210
|
+
grid-template-columns: repeat(2, 1fr);
|
|
211
|
+
gap: 16px;
|
|
212
|
+
margin-bottom: 18px;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* ── Notas e legenda ── */
|
|
216
|
+
.note {
|
|
217
|
+
background: ${mutedBg};
|
|
218
|
+
border: 1px solid ${line};
|
|
219
|
+
border-radius: 8px;
|
|
220
|
+
padding: 9px 12px;
|
|
221
|
+
font-size: 10px;
|
|
222
|
+
color: ${mutedFg};
|
|
223
|
+
margin: 12px 0;
|
|
224
|
+
break-inside: avoid;
|
|
225
|
+
}
|
|
226
|
+
.note-accent { border-left: 3px solid ${primary}; }
|
|
227
|
+
.legend {
|
|
228
|
+
margin-top: 18px;
|
|
229
|
+
padding-top: 10px;
|
|
230
|
+
border-top: 1px solid ${line};
|
|
231
|
+
font-size: 9px;
|
|
232
|
+
line-height: 1.6;
|
|
233
|
+
color: ${mutedFg};
|
|
234
|
+
}
|
|
235
|
+
.legend p { margin: 0 0 3px; }
|
|
236
|
+
.page-break { break-before: page; }
|
|
237
|
+
`;
|
|
238
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals';
|
|
2
|
+
|
|
3
|
+
import { escapeHtml, renderBlock, renderDocumentHtml } from './pdf-document.renderer';
|
|
4
|
+
import { PdfDocumentTheme } from './pdf-document.theme';
|
|
5
|
+
import { PdfDocumentBlock, PdfDocumentRow } from './pdf-document.types';
|
|
6
|
+
|
|
7
|
+
const theme: PdfDocumentTheme = {
|
|
8
|
+
primary: '#ff760c',
|
|
9
|
+
mutedBg: '#f4f4f5',
|
|
10
|
+
mutedFg: '#71717a',
|
|
11
|
+
ink: '#18181b',
|
|
12
|
+
line: '#e4e4e7',
|
|
13
|
+
companyName: 'HedHog',
|
|
14
|
+
logoUrl: 'data:image/svg+xml;charset=UTF-8,%3Csvg%3E%3C/svg%3E',
|
|
15
|
+
fontFamily: 'Arial, Helvetica, sans-serif',
|
|
16
|
+
chartPalette: ['#ff760c'],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const table = (rows: PdfDocumentRow[]): PdfDocumentBlock => ({
|
|
20
|
+
kind: 'table',
|
|
21
|
+
numbered: true,
|
|
22
|
+
columns: [{ header: 'Aluno' }, { header: 'Horas', align: 'right' }],
|
|
23
|
+
rows,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('escapeHtml', () => {
|
|
27
|
+
it('neutraliza os cinco caracteres que viram markup', () => {
|
|
28
|
+
expect(escapeHtml(`<script>alert("1" & '2')</script>`)).toBe(
|
|
29
|
+
'<script>alert("1" & '2')</script>',
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('trata nulo e indefinido como texto vazio', () => {
|
|
34
|
+
expect(escapeHtml(null)).toBe('');
|
|
35
|
+
expect(escapeHtml(undefined)).toBe('');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('tabela', () => {
|
|
40
|
+
// O pedido que originou o kit: cada linha numerada a partir de 1.
|
|
41
|
+
it('numera as linhas de 1 a N, na ordem recebida', () => {
|
|
42
|
+
const html = renderBlock(table([['Ana', '10h'], ['Bruno', '9h'], ['Carla', '8h']]));
|
|
43
|
+
const indices = [...html.matchAll(/<td class="idx">(\d+)<\/td>/g)].map((m) => m[1]);
|
|
44
|
+
|
|
45
|
+
expect(indices).toEqual(['1', '2', '3']);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Sem `display: table-header-group` o cabeçalho some da segunda página em
|
|
49
|
+
// diante; o `<thead>` tem que existir de verdade para a regra pegar.
|
|
50
|
+
it('emite thead próprio, que o CSS manda repetir por página', () => {
|
|
51
|
+
const html = renderBlock(table([['Ana', '10h']]));
|
|
52
|
+
|
|
53
|
+
expect(html).toContain('<thead><tr>');
|
|
54
|
+
expect(html).toContain('<th class="idx">#</th>');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('escapa o conteúdo das células e da segunda linha', () => {
|
|
58
|
+
const html = renderBlock({
|
|
59
|
+
kind: 'table',
|
|
60
|
+
columns: [{ header: 'Aluno' }],
|
|
61
|
+
rows: [[{ text: '<b>Ana</b>', sub: 'a&b@x.com' }]],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(html).toContain('<b>Ana</b>');
|
|
65
|
+
expect(html).toContain('a&b@x.com');
|
|
66
|
+
expect(html).not.toContain('<b>Ana</b>');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('mostra um aviso no lugar da tabela quando não há linha nenhuma', () => {
|
|
70
|
+
const html = renderBlock({ kind: 'table', columns: [{ header: 'Aluno' }], rows: [] });
|
|
71
|
+
|
|
72
|
+
expect(html).toContain('table-empty');
|
|
73
|
+
expect(html).not.toContain('<table');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('preenche célula vazia com travessão em vez de deixar buraco', () => {
|
|
77
|
+
const html = renderBlock(table([['Ana', null]]));
|
|
78
|
+
|
|
79
|
+
expect(html).toContain('—');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('documento', () => {
|
|
84
|
+
it('monta o letterhead com sobretítulo, título e metadados', () => {
|
|
85
|
+
const html = renderDocumentHtml(
|
|
86
|
+
{
|
|
87
|
+
title: 'Alunos',
|
|
88
|
+
eyebrow: 'Relatório de Alunos',
|
|
89
|
+
subtitle: 'BRClick',
|
|
90
|
+
meta: [{ label: 'Status', value: 'Todos os status' }],
|
|
91
|
+
generatedAt: new Date('2026-09-22T13:32:00Z'),
|
|
92
|
+
blocks: [{ kind: 'section', title: 'Resumo' }],
|
|
93
|
+
},
|
|
94
|
+
theme,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
expect(html).toContain('<div class="eyebrow">Relatório de Alunos</div>');
|
|
98
|
+
expect(html).toContain('<h1>Alunos</h1>');
|
|
99
|
+
expect(html).toContain('Status: <span class="meta-value">Todos os status</span>');
|
|
100
|
+
expect(html).toContain('Gerado em:');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('escapa o título também no <title> da página', () => {
|
|
104
|
+
const html = renderDocumentHtml(
|
|
105
|
+
{ title: 'Turma <A>', blocks: [] },
|
|
106
|
+
theme,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(html).toContain('<title>Turma <A></title>');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// O SVG é a única string injetada crua — e só os desenhistas do kit produzem.
|
|
113
|
+
it('injeta o SVG do gráfico sem escapar, mas escapa o título dele', () => {
|
|
114
|
+
const html = renderBlock({
|
|
115
|
+
kind: 'chart',
|
|
116
|
+
title: 'Matrículas & conclusões',
|
|
117
|
+
svg: '<svg><rect /></svg>' as never,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(html).toContain('<svg><rect /></svg>');
|
|
121
|
+
expect(html).toContain('Matrículas & conclusões');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('vira paisagem quando o documento pede', () => {
|
|
125
|
+
const html = renderDocumentHtml({ title: 'Frequência', landscape: true, blocks: [] }, theme);
|
|
126
|
+
|
|
127
|
+
expect(html).toContain('size: A4 landscape');
|
|
128
|
+
});
|
|
129
|
+
});
|