@hed-hog/core 0.0.400 → 0.0.401

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/dist/ai/ai.controller.d.ts +2 -0
  2. package/dist/ai/ai.controller.d.ts.map +1 -1
  3. package/dist/ai/ai.service.d.ts +86 -2
  4. package/dist/ai/ai.service.d.ts.map +1 -1
  5. package/dist/ai/ai.service.js +304 -23
  6. package/dist/ai/ai.service.js.map +1 -1
  7. package/dist/ai-instruction/ai-prompt-resolver.service.d.ts +12 -0
  8. package/dist/ai-instruction/ai-prompt-resolver.service.d.ts.map +1 -1
  9. package/dist/ai-instruction/ai-prompt-resolver.service.js +26 -0
  10. package/dist/ai-instruction/ai-prompt-resolver.service.js.map +1 -1
  11. package/dist/core.module.d.ts.map +1 -1
  12. package/dist/core.module.js +3 -0
  13. package/dist/core.module.js.map +1 -1
  14. package/dist/document-kit/document-kit.module.d.ts +3 -0
  15. package/dist/document-kit/document-kit.module.d.ts.map +1 -0
  16. package/dist/document-kit/document-kit.module.js +24 -0
  17. package/dist/document-kit/document-kit.module.js.map +1 -0
  18. package/dist/document-kit/document-kit.registry.d.ts +55 -0
  19. package/dist/document-kit/document-kit.registry.d.ts.map +1 -0
  20. package/dist/document-kit/document-kit.registry.js +91 -0
  21. package/dist/document-kit/document-kit.registry.js.map +1 -0
  22. package/dist/document-kit/document-kit.service.d.ts +49 -0
  23. package/dist/document-kit/document-kit.service.d.ts.map +1 -0
  24. package/dist/document-kit/document-kit.service.js +135 -0
  25. package/dist/document-kit/document-kit.service.js.map +1 -0
  26. package/dist/file/file.service.d.ts +15 -1
  27. package/dist/file/file.service.d.ts.map +1 -1
  28. package/dist/file/file.service.js +52 -24
  29. package/dist/file/file.service.js.map +1 -1
  30. package/dist/file/mcp-file-source.schema.d.ts.map +1 -1
  31. package/dist/file/mcp-file-source.schema.js +8 -5
  32. package/dist/file/mcp-file-source.schema.js.map +1 -1
  33. package/dist/index.d.ts +5 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +5 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
  38. package/dist/integration-profile/integration-profile.secrets.js +21 -0
  39. package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
  40. package/dist/integration-profile/testers/ai.testers.d.ts.map +1 -1
  41. package/dist/integration-profile/testers/ai.testers.js +46 -8
  42. package/dist/integration-profile/testers/ai.testers.js.map +1 -1
  43. package/dist/mcp/mcp.module.d.ts.map +1 -1
  44. package/dist/mcp/mcp.module.js +2 -0
  45. package/dist/mcp/mcp.module.js.map +1 -1
  46. package/dist/mcp/tools/core-access-log.mcp-tools.d.ts +17 -0
  47. package/dist/mcp/tools/core-access-log.mcp-tools.d.ts.map +1 -0
  48. package/dist/mcp/tools/core-access-log.mcp-tools.js +74 -0
  49. package/dist/mcp/tools/core-access-log.mcp-tools.js.map +1 -0
  50. package/dist/mcp/tools/core-files.mcp-tools.js +4 -4
  51. package/dist/mcp/tools/core-files.mcp-tools.js.map +1 -1
  52. package/dist/meeting/meeting-provider.registry.d.ts +72 -0
  53. package/dist/meeting/meeting-provider.registry.d.ts.map +1 -1
  54. package/dist/meeting/meeting-provider.registry.js.map +1 -1
  55. package/dist/oauth/oauth-token-broker.service.d.ts +50 -1
  56. package/dist/oauth/oauth-token-broker.service.d.ts.map +1 -1
  57. package/dist/oauth/oauth-token-broker.service.js +105 -3
  58. package/dist/oauth/oauth-token-broker.service.js.map +1 -1
  59. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts +6 -0
  60. package/dist/oauth/providers/microsoft-entra-id.provider.d.ts.map +1 -1
  61. package/dist/oauth/providers/microsoft-entra-id.provider.js +30 -4
  62. package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
  63. package/dist/pdf/document/charts/chart-bar.d.ts +24 -0
  64. package/dist/pdf/document/charts/chart-bar.d.ts.map +1 -0
  65. package/dist/pdf/document/charts/chart-bar.js +117 -0
  66. package/dist/pdf/document/charts/chart-bar.js.map +1 -0
  67. package/dist/pdf/document/charts/chart-donut.d.ts +16 -0
  68. package/dist/pdf/document/charts/chart-donut.d.ts.map +1 -0
  69. package/dist/pdf/document/charts/chart-donut.js +89 -0
  70. package/dist/pdf/document/charts/chart-donut.js.map +1 -0
  71. package/dist/pdf/document/charts/chart-gauge.d.ts +15 -0
  72. package/dist/pdf/document/charts/chart-gauge.d.ts.map +1 -0
  73. package/dist/pdf/document/charts/chart-gauge.js +60 -0
  74. package/dist/pdf/document/charts/chart-gauge.js.map +1 -0
  75. package/dist/pdf/document/charts/chart-line.d.ts +29 -0
  76. package/dist/pdf/document/charts/chart-line.d.ts.map +1 -0
  77. package/dist/pdf/document/charts/chart-line.js +89 -0
  78. package/dist/pdf/document/charts/chart-line.js.map +1 -0
  79. package/dist/pdf/document/charts/chart-primitives.d.ts +64 -0
  80. package/dist/pdf/document/charts/chart-primitives.d.ts.map +1 -0
  81. package/dist/pdf/document/charts/chart-primitives.js +110 -0
  82. package/dist/pdf/document/charts/chart-primitives.js.map +1 -0
  83. package/dist/pdf/document/charts/chart-radar.d.ts +13 -0
  84. package/dist/pdf/document/charts/chart-radar.d.ts.map +1 -0
  85. package/dist/pdf/document/charts/chart-radar.js +83 -0
  86. package/dist/pdf/document/charts/chart-radar.js.map +1 -0
  87. package/dist/pdf/document/charts/chart-scale.d.ts +39 -0
  88. package/dist/pdf/document/charts/chart-scale.d.ts.map +1 -0
  89. package/dist/pdf/document/charts/chart-scale.js +118 -0
  90. package/dist/pdf/document/charts/chart-scale.js.map +1 -0
  91. package/dist/pdf/document/charts/index.d.ts +8 -0
  92. package/dist/pdf/document/charts/index.d.ts.map +1 -0
  93. package/dist/pdf/document/charts/index.js +24 -0
  94. package/dist/pdf/document/charts/index.js.map +1 -0
  95. package/dist/pdf/document/index.d.ts +8 -0
  96. package/dist/pdf/document/index.d.ts.map +1 -0
  97. package/dist/pdf/document/index.js +24 -0
  98. package/dist/pdf/document/index.js.map +1 -0
  99. package/dist/pdf/document/pdf-document.chrome.d.ts +20 -0
  100. package/dist/pdf/document/pdf-document.chrome.d.ts.map +1 -0
  101. package/dist/pdf/document/pdf-document.chrome.js +36 -0
  102. package/dist/pdf/document/pdf-document.chrome.js.map +1 -0
  103. package/dist/pdf/document/pdf-document.css.d.ts +20 -0
  104. package/dist/pdf/document/pdf-document.css.d.ts.map +1 -0
  105. package/dist/pdf/document/pdf-document.css.js +239 -0
  106. package/dist/pdf/document/pdf-document.css.js.map +1 -0
  107. package/dist/pdf/document/pdf-document.renderer.d.ts +10 -0
  108. package/dist/pdf/document/pdf-document.renderer.d.ts.map +1 -0
  109. package/dist/pdf/document/pdf-document.renderer.js +153 -0
  110. package/dist/pdf/document/pdf-document.renderer.js.map +1 -0
  111. package/dist/pdf/document/pdf-document.service.d.ts +27 -0
  112. package/dist/pdf/document/pdf-document.service.d.ts.map +1 -0
  113. package/dist/pdf/document/pdf-document.service.js +81 -0
  114. package/dist/pdf/document/pdf-document.service.js.map +1 -0
  115. package/dist/pdf/document/pdf-document.theme.d.ts +41 -0
  116. package/dist/pdf/document/pdf-document.theme.d.ts.map +1 -0
  117. package/dist/pdf/document/pdf-document.theme.js +105 -0
  118. package/dist/pdf/document/pdf-document.theme.js.map +1 -0
  119. package/dist/pdf/document/pdf-document.types.d.ts +108 -0
  120. package/dist/pdf/document/pdf-document.types.d.ts.map +1 -0
  121. package/dist/pdf/document/pdf-document.types.js +18 -0
  122. package/dist/pdf/document/pdf-document.types.js.map +1 -0
  123. package/dist/pdf/pdf.module.d.ts.map +1 -1
  124. package/dist/pdf/pdf.module.js +3 -2
  125. package/dist/pdf/pdf.module.js.map +1 -1
  126. package/dist/pdf/pdf.service.d.ts +69 -1
  127. package/dist/pdf/pdf.service.d.ts.map +1 -1
  128. package/dist/pdf/pdf.service.js +164 -63
  129. package/dist/pdf/pdf.service.js.map +1 -1
  130. package/dist/pdf/templates/commercial-proposal.template.d.ts +96 -0
  131. package/dist/pdf/templates/commercial-proposal.template.d.ts.map +1 -0
  132. package/dist/pdf/templates/commercial-proposal.template.js +276 -0
  133. package/dist/pdf/templates/commercial-proposal.template.js.map +1 -0
  134. package/dist/setting/favicon.controller.d.ts +26 -0
  135. package/dist/setting/favicon.controller.d.ts.map +1 -0
  136. package/dist/setting/favicon.controller.js +130 -0
  137. package/dist/setting/favicon.controller.js.map +1 -0
  138. package/dist/setting/setting.module.d.ts.map +1 -1
  139. package/dist/setting/setting.module.js +2 -1
  140. package/dist/setting/setting.module.js.map +1 -1
  141. package/dist/whatsapp-sender/whatsapp-sender.service.d.ts.map +1 -1
  142. package/dist/whatsapp-sender/whatsapp-sender.service.js +25 -2
  143. package/dist/whatsapp-sender/whatsapp-sender.service.js.map +1 -1
  144. package/hedhog/data/integration_type.yaml +6 -0
  145. package/hedhog/data/route.yaml +8 -0
  146. package/hedhog/data/setting_group.yaml +52 -0
  147. package/hedhog/frontend/messages/en.json +54 -1
  148. package/hedhog/frontend/messages/pt.json +54 -1
  149. package/package.json +4 -4
  150. package/src/ai/ai-attachment.fragments.spec.ts +56 -3
  151. package/src/ai/ai.service.responses.spec.ts +429 -0
  152. package/src/ai/ai.service.ts +409 -27
  153. package/src/ai-instruction/ai-prompt-resolver.service.spec.ts +44 -0
  154. package/src/ai-instruction/ai-prompt-resolver.service.ts +29 -0
  155. package/src/core.module.ts +3 -0
  156. package/src/document-kit/document-kit.module.ts +12 -0
  157. package/src/document-kit/document-kit.registry.ts +125 -0
  158. package/src/document-kit/document-kit.service.spec.ts +156 -0
  159. package/src/document-kit/document-kit.service.ts +173 -0
  160. package/src/file/file.service.spec.ts +168 -1
  161. package/src/file/file.service.ts +72 -34
  162. package/src/file/mcp-file-source.schema.ts +8 -5
  163. package/src/index.ts +5 -0
  164. package/src/integration-profile/integration-profile.secrets.ts +21 -0
  165. package/src/integration-profile/testers/ai.testers.ts +70 -5
  166. package/src/integration-profile/testers/remaining.testers.spec.ts +65 -1
  167. package/src/integration-profile/testers/tester-coverage.spec.ts +10 -0
  168. package/src/mcp/mcp.module.ts +2 -0
  169. package/src/mcp/tools/core-access-log.mcp-tools.ts +66 -0
  170. package/src/mcp/tools/core-files.mcp-tools.ts +4 -4
  171. package/src/meeting/meeting-provider.registry.ts +80 -0
  172. package/src/oauth/oauth-token-broker.service.spec.ts +108 -0
  173. package/src/oauth/oauth-token-broker.service.ts +129 -2
  174. package/src/oauth/providers/microsoft-entra-id.provider.spec.ts +133 -0
  175. package/src/oauth/providers/microsoft-entra-id.provider.ts +29 -2
  176. package/src/pdf/document/charts/chart-bar.ts +217 -0
  177. package/src/pdf/document/charts/chart-donut.ts +163 -0
  178. package/src/pdf/document/charts/chart-gauge.ts +94 -0
  179. package/src/pdf/document/charts/chart-line.ts +159 -0
  180. package/src/pdf/document/charts/chart-primitives.ts +188 -0
  181. package/src/pdf/document/charts/chart-radar.ts +136 -0
  182. package/src/pdf/document/charts/chart-scale.spec.ts +93 -0
  183. package/src/pdf/document/charts/chart-scale.ts +141 -0
  184. package/src/pdf/document/charts/charts.spec.ts +157 -0
  185. package/src/pdf/document/charts/index.ts +7 -0
  186. package/src/pdf/document/index.ts +7 -0
  187. package/src/pdf/document/pdf-document.chrome.ts +38 -0
  188. package/src/pdf/document/pdf-document.css.ts +238 -0
  189. package/src/pdf/document/pdf-document.renderer.spec.ts +129 -0
  190. package/src/pdf/document/pdf-document.renderer.ts +215 -0
  191. package/src/pdf/document/pdf-document.service.ts +77 -0
  192. package/src/pdf/document/pdf-document.theme.ts +146 -0
  193. package/src/pdf/document/pdf-document.types.ts +97 -0
  194. package/src/pdf/pdf.module.ts +3 -2
  195. package/src/pdf/pdf.service.spec.ts +131 -0
  196. package/src/pdf/pdf.service.ts +245 -82
  197. package/src/pdf/templates/commercial-proposal.template.spec.ts +177 -0
  198. package/src/pdf/templates/commercial-proposal.template.ts +400 -0
  199. package/src/setting/favicon.controller.spec.ts +92 -0
  200. package/src/setting/favicon.controller.ts +135 -0
  201. package/src/setting/setting.module.ts +2 -1
  202. package/src/whatsapp-sender/whatsapp-sender.service.spec.ts +73 -0
  203. package/src/whatsapp-sender/whatsapp-sender.service.ts +32 -2
@@ -77,6 +77,19 @@ export class MicrosoftEntraIdProvider extends BaseOAuthProvider {
77
77
  };
78
78
  }
79
79
 
80
+ /**
81
+ * Escopos que a Microsoft devolveu no token, ou `null` quando ela não
82
+ * informou — só nesse caso vale cair de volta para os escopos pedidos.
83
+ * Uma lista vazia é resposta legítima e não pode virar "assume o pedido".
84
+ */
85
+ private parseGrantedScopes(scope: unknown): string[] | null {
86
+ if (typeof scope !== 'string') return null;
87
+ return scope
88
+ .split(/[\s,]+/)
89
+ .map((item) => item.trim())
90
+ .filter(Boolean);
91
+ }
92
+
80
93
  /** Absolute redirect URI the web flow registers/exchanges against (the API). */
81
94
  private buildApiRedirectUri(apiUrl: string) {
82
95
  if (!String(apiUrl).trim()) {
@@ -155,8 +168,22 @@ export class MicrosoftEntraIdProvider extends BaseOAuthProvider {
155
168
  'https://graph.microsoft.com/v1.0/me',
156
169
  );
157
170
 
171
+ // `token.scope` é o que a Microsoft de fato consentiu, que pode ser MENOS
172
+ // do que a setting pede — consentimento antigo do usuário, ou escopo novo
173
+ // ainda sem aprovação do admin. Gravar a setting como se fosse o concedido
174
+ // faz o refresh seguinte pedir escopo que o grant não cobre, e aí a
175
+ // resposta é `invalid_grant`: a UI diz "reconecte a conta", e reconectar
176
+ // pelo login regrava a mesma setting, então nunca sai do lugar.
177
+ const grantedScopes = this.parseGrantedScopes(token.scope) ?? scopes;
178
+ // A Microsoft não repete `offline_access` em `token.scope`, mas ter
179
+ // devolvido um refresh token é prova de que foi concedido.
180
+ if (token.refresh_token && !grantedScopes.includes('offline_access')) {
181
+ grantedScopes.push('offline_access');
182
+ }
183
+ const missing = scopes.filter((scope) => !grantedScopes.includes(scope));
184
+
158
185
  this.logger.log(
159
- `OAuth profile fetched: provider=MICROSOFT-ENTRA-ID type=${type} profileIdPresent=${profile?.id ? 'yes' : 'no'} emailPresent=${profile?.mail || profile?.userPrincipalName ? 'yes' : 'no'}`,
186
+ `OAuth profile fetched: provider=MICROSOFT-ENTRA-ID type=${type} profileIdPresent=${profile?.id ? 'yes' : 'no'} emailPresent=${profile?.mail || profile?.userPrincipalName ? 'yes' : 'no'} scopesGranted=${grantedScopes.length}/${scopes.length}${missing.length ? ` missing=${missing.join(' ')}` : ''}`,
160
187
  );
161
188
 
162
189
  const pictureUrl = 'https://graph.microsoft.com/v1.0/me/photo/$value';
@@ -165,7 +192,7 @@ export class MicrosoftEntraIdProvider extends BaseOAuthProvider {
165
192
  email: profile.mail || profile.userPrincipalName,
166
193
  name: profile.displayName,
167
194
  picture: pictureUrl,
168
- oauth_scopes: scopes.join(','),
195
+ oauth_scopes: grantedScopes.join(','),
169
196
  oauth_tokens: {
170
197
  access_token: token.access_token,
171
198
  refresh_token: token.refresh_token,
@@ -0,0 +1,217 @@
1
+ import { SafeSvg } from '../pdf-document.types';
2
+ import {
3
+ CHART_DEFAULT_HEIGHT,
4
+ CHART_FONT,
5
+ CHART_FULL_WIDTH,
6
+ ChartBaseOptions,
7
+ ChartSeries,
8
+ chartColor,
9
+ emptyChart,
10
+ legendRows,
11
+ line,
12
+ svgRoot,
13
+ text,
14
+ xAxisLabels,
15
+ yAxis,
16
+ } from './chart-primitives';
17
+ import {
18
+ axisLabelPlan,
19
+ bandScale,
20
+ estimateTextWidth,
21
+ formatNumber,
22
+ linearScale,
23
+ niceScale,
24
+ truncateToWidth,
25
+ } from './chart-scale';
26
+
27
+ const hasData = (series: ChartSeries[]): boolean =>
28
+ series.some((s) => s.values.some((v) => typeof v === 'number' && Number.isFinite(v)));
29
+
30
+ const maxOf = (series: ChartSeries[], stacked: boolean): number => {
31
+ if (!stacked) {
32
+ return series.reduce(
33
+ (max, s) => Math.max(max, ...s.values.map((v) => (typeof v === 'number' ? v : 0))),
34
+ 0,
35
+ );
36
+ }
37
+
38
+ const length = series.reduce((max, s) => Math.max(max, s.values.length), 0);
39
+ let max = 0;
40
+ for (let i = 0; i < length; i += 1) {
41
+ max = Math.max(
42
+ max,
43
+ series.reduce((sum, s) => sum + (typeof s.values[i] === 'number' ? (s.values[i] as number) : 0), 0),
44
+ );
45
+ }
46
+ return max;
47
+ };
48
+
49
+ /** Barras verticais, uma faixa por categoria. */
50
+ export function barChartSvg(
51
+ options: ChartBaseOptions & {
52
+ categories: string[];
53
+ series: ChartSeries[];
54
+ stacked?: boolean;
55
+ yMax?: number;
56
+ yTicks?: number;
57
+ showValues?: boolean;
58
+ legend?: boolean;
59
+ },
60
+ ): SafeSvg {
61
+ const {
62
+ theme,
63
+ categories,
64
+ series,
65
+ stacked = false,
66
+ width = CHART_FULL_WIDTH,
67
+ height = CHART_DEFAULT_HEIGHT,
68
+ valueFormat = (value: number) => formatNumber(value),
69
+ } = options;
70
+
71
+ if (categories.length === 0 || !hasData(series)) return emptyChart(options);
72
+
73
+ const showLegend = options.legend ?? series.length > 1;
74
+ const legendHeight = showLegend ? 16 : 0;
75
+ const plot = { left: 42, right: width - 8, top: 10, bottom: height - 26 - legendHeight };
76
+
77
+ const scale = niceScale(0, options.yMax ?? maxOf(series, stacked), options.yTicks ?? 4);
78
+ const y = linearScale([scale.min, scale.max], [plot.bottom, plot.top]);
79
+ const band = bandScale(categories.length, [plot.left, plot.right]);
80
+
81
+ const parts: string[] = [
82
+ yAxis(scale.ticks, y, plot, theme, valueFormat),
83
+ line(plot.left, plot.bottom, plot.right, plot.bottom, theme.mutedFg),
84
+ ];
85
+
86
+ const groupWidth = stacked ? band.bandwidth : band.bandwidth / series.length;
87
+
88
+ series.forEach((serie, serieIndex) => {
89
+ const color = chartColor(theme, serieIndex, serie.color);
90
+ const stackTops = new Array(categories.length).fill(plot.bottom);
91
+
92
+ categories.forEach((_, index) => {
93
+ const raw = serie.values[index];
94
+ if (typeof raw !== 'number' || !Number.isFinite(raw)) return;
95
+
96
+ const barX = stacked ? band.band(index) : band.band(index) + groupWidth * serieIndex;
97
+ const top = stacked ? stackTops[index] - (plot.bottom - y(raw)) : y(raw);
98
+ const barHeight = Math.max(plot.bottom - y(raw), raw > 0 ? 1 : 0);
99
+
100
+ if (stacked) stackTops[index] = top;
101
+
102
+ parts.push(
103
+ `<rect x="${barX.toFixed(1)}" y="${top.toFixed(1)}" width="${groupWidth.toFixed(1)}" ` +
104
+ `height="${barHeight.toFixed(1)}" fill="${color}" rx="1.5" />`,
105
+ );
106
+
107
+ // O valor só entra quando cabe dentro da largura da barra; senão vira
108
+ // sopa de números sobrepostos.
109
+ if (
110
+ options.showValues &&
111
+ !stacked &&
112
+ estimateTextWidth(valueFormat(raw), CHART_FONT.value) < groupWidth
113
+ ) {
114
+ parts.push(
115
+ text(valueFormat(raw), barX + groupWidth / 2, top - 3, {
116
+ size: CHART_FONT.value,
117
+ fill: theme.ink,
118
+ anchor: 'middle',
119
+ weight: 600,
120
+ }),
121
+ );
122
+ }
123
+ });
124
+ });
125
+
126
+ const plan = axisLabelPlan(categories, band.bandwidth, CHART_FONT.axis);
127
+ parts.push(xAxisLabels(categories, band.center, plot.bottom + 12, plan, band.bandwidth, theme));
128
+
129
+ if (showLegend) {
130
+ const legend = legendRows(
131
+ series.map((serie, index) => ({
132
+ label: serie.name,
133
+ color: chartColor(theme, index, serie.color),
134
+ })),
135
+ width,
136
+ height - 4,
137
+ theme,
138
+ );
139
+ parts.push(legend.markup);
140
+ }
141
+
142
+ return svgRoot(width, height, parts.join(''), options.ariaLabel);
143
+ }
144
+
145
+ /**
146
+ * Barras horizontais — a forma certa quando o rótulo é um nome (curso, aluno),
147
+ * que deitado vira texto girado e ilegível.
148
+ */
149
+ export function horizontalBarChartSvg(
150
+ options: ChartBaseOptions & {
151
+ categories: string[];
152
+ series: ChartSeries[];
153
+ xMax?: number;
154
+ labelWidth?: number;
155
+ showValues?: boolean;
156
+ },
157
+ ): SafeSvg {
158
+ const {
159
+ theme,
160
+ categories,
161
+ series,
162
+ width = CHART_FULL_WIDTH,
163
+ valueFormat = (value: number) => formatNumber(value),
164
+ showValues = true,
165
+ } = options;
166
+
167
+ if (categories.length === 0 || !hasData(series)) return emptyChart(options);
168
+
169
+ const rowHeight = 22;
170
+ const height = options.height ?? Math.max(categories.length * rowHeight + 24, 90);
171
+ const labelWidth = options.labelWidth ?? Math.min(Math.round(width * 0.34), 180);
172
+ const plot = { left: labelWidth + 8, right: width - 38, top: 8, bottom: height - 16 };
173
+
174
+ const scale = niceScale(0, options.xMax ?? maxOf(series, false), 4);
175
+ const x = linearScale([scale.min, scale.max], [plot.left, plot.right]);
176
+ const band = bandScale(categories.length, [plot.top, plot.bottom], 0.25);
177
+
178
+ const parts: string[] = scale.ticks.map((tick) =>
179
+ line(x(tick), plot.top, x(tick), plot.bottom, theme.line),
180
+ );
181
+
182
+ categories.forEach((category, index) => {
183
+ parts.push(
184
+ text(truncateToWidth(category, CHART_FONT.axis + 1, labelWidth), labelWidth, band.center(index) + 3, {
185
+ size: CHART_FONT.axis + 1,
186
+ fill: theme.ink,
187
+ anchor: 'end',
188
+ }),
189
+ );
190
+
191
+ const barHeight = band.bandwidth / series.length;
192
+
193
+ series.forEach((serie, serieIndex) => {
194
+ const raw = serie.values[index];
195
+ if (typeof raw !== 'number' || !Number.isFinite(raw)) return;
196
+
197
+ const barY = band.band(index) + barHeight * serieIndex;
198
+ const barWidth = Math.max(x(raw) - plot.left, raw > 0 ? 1 : 0);
199
+
200
+ parts.push(
201
+ `<rect x="${plot.left}" y="${barY.toFixed(1)}" width="${barWidth.toFixed(1)}" ` +
202
+ `height="${barHeight.toFixed(1)}" fill="${chartColor(theme, serieIndex, serie.color)}" rx="1.5" />`,
203
+ );
204
+
205
+ if (showValues && series.length === 1) {
206
+ parts.push(
207
+ text(valueFormat(raw), plot.left + barWidth + 4, barY + barHeight / 2 + 3, {
208
+ size: CHART_FONT.value,
209
+ fill: theme.mutedFg,
210
+ }),
211
+ );
212
+ }
213
+ });
214
+ });
215
+
216
+ return svgRoot(width, height, parts.join(''), options.ariaLabel);
217
+ }
@@ -0,0 +1,163 @@
1
+ import { SafeSvg } from '../pdf-document.types';
2
+ import {
3
+ CHART_DEFAULT_HEIGHT,
4
+ CHART_FONT,
5
+ CHART_FULL_WIDTH,
6
+ ChartBaseOptions,
7
+ chartColor,
8
+ emptyChart,
9
+ svgRoot,
10
+ text,
11
+ } from './chart-primitives';
12
+ import { estimateTextWidth, formatNumber, truncateToWidth } from './chart-scale';
13
+
14
+ export interface DonutSlice {
15
+ label: string;
16
+ value: number;
17
+ color?: string;
18
+ }
19
+
20
+ const polar = (cx: number, cy: number, radius: number, angle: number) => ({
21
+ x: cx + radius * Math.cos(angle),
22
+ y: cy + radius * Math.sin(angle),
23
+ });
24
+
25
+ /** Setor em anel, desenhado com dois arcos — o `path` é o único jeito no SVG puro. */
26
+ function ringSegment(
27
+ cx: number,
28
+ cy: number,
29
+ outer: number,
30
+ inner: number,
31
+ start: number,
32
+ end: number,
33
+ ): string {
34
+ const largeArc = end - start > Math.PI ? 1 : 0;
35
+ const p1 = polar(cx, cy, outer, start);
36
+ const p2 = polar(cx, cy, outer, end);
37
+ const p3 = polar(cx, cy, inner, end);
38
+ const p4 = polar(cx, cy, inner, start);
39
+
40
+ if (inner <= 0) {
41
+ return (
42
+ `M${cx.toFixed(1)},${cy.toFixed(1)} L${p1.x.toFixed(1)},${p1.y.toFixed(1)} ` +
43
+ `A${outer},${outer} 0 ${largeArc} 1 ${p2.x.toFixed(1)},${p2.y.toFixed(1)} Z`
44
+ );
45
+ }
46
+
47
+ return (
48
+ `M${p1.x.toFixed(1)},${p1.y.toFixed(1)} A${outer},${outer} 0 ${largeArc} 1 ${p2.x.toFixed(1)},${p2.y.toFixed(1)} ` +
49
+ `L${p3.x.toFixed(1)},${p3.y.toFixed(1)} A${inner},${inner} 0 ${largeArc} 0 ${p4.x.toFixed(1)},${p4.y.toFixed(1)} Z`
50
+ );
51
+ }
52
+
53
+ /** Rosca (ou pizza, com `innerRadiusRatio: 0`) com legenda à direita. */
54
+ export function donutChartSvg(
55
+ options: ChartBaseOptions & {
56
+ slices: DonutSlice[];
57
+ innerRadiusRatio?: number;
58
+ centerLabel?: string;
59
+ centerValue?: string;
60
+ showPercent?: boolean;
61
+ },
62
+ ): SafeSvg {
63
+ const {
64
+ theme,
65
+ slices,
66
+ width = CHART_FULL_WIDTH,
67
+ height = CHART_DEFAULT_HEIGHT,
68
+ innerRadiusRatio = 0.62,
69
+ showPercent = true,
70
+ valueFormat = (value: number) => formatNumber(value, 0),
71
+ } = options;
72
+
73
+ const usable = slices.filter((slice) => Number.isFinite(slice.value) && slice.value > 0);
74
+ const total = usable.reduce((sum, slice) => sum + slice.value, 0);
75
+
76
+ if (total <= 0) return emptyChart(options);
77
+
78
+ // A coluna da direita é dimensionada pelo texto que realmente vai nela: o
79
+ // valor ("318 · 76%") fixa a largura mínima, e o que sobra é do rótulo.
80
+ const valueLabels = usable.map((slice) =>
81
+ showPercent
82
+ ? `${valueFormat(slice.value)} · ${Math.round((slice.value / total) * 100)}%`
83
+ : valueFormat(slice.value),
84
+ );
85
+ const valueWidth = Math.max(
86
+ ...valueLabels.map((label) => estimateTextWidth(label, CHART_FONT.legend)),
87
+ );
88
+ // A largura do rótulo é decidida primeiro e a legenda é montada a partir dela.
89
+ // Derivar na ordem inversa (legenda menos valor menos folga) devolve o mesmo
90
+ // número com um epsilon de ponto flutuante a menos, e um rótulo que cabia
91
+ // certinho sai cortado — "Em risco" virava "Em ris…".
92
+ const maxLabelWidth = Math.min(
93
+ Math.max(...usable.map((s) => estimateTextWidth(s.label, CHART_FONT.legend))),
94
+ width * 0.45 - valueWidth - 26,
95
+ );
96
+ const legendWidth = maxLabelWidth + valueWidth + 26;
97
+ const chartWidth = width - legendWidth;
98
+ const radius = Math.min(chartWidth, height) / 2 - 8;
99
+ const cx = chartWidth / 2;
100
+ const cy = height / 2;
101
+ const inner = radius * innerRadiusRatio;
102
+
103
+ const parts: string[] = [];
104
+ // Começa às 12h: é onde o olho procura o início de uma rosca.
105
+ let angle = -Math.PI / 2;
106
+
107
+ usable.forEach((slice, index) => {
108
+ const sweep = (slice.value / total) * Math.PI * 2;
109
+ const color = chartColor(theme, index, slice.color);
110
+
111
+ parts.push(
112
+ `<path d="${ringSegment(cx, cy, radius, inner, angle, angle + sweep)}" fill="${color}" />`,
113
+ );
114
+ angle += sweep;
115
+ });
116
+
117
+ if (inner > 0 && (options.centerValue || options.centerLabel)) {
118
+ if (options.centerValue) {
119
+ parts.push(
120
+ text(options.centerValue, cx, cy + (options.centerLabel ? 0 : 4), {
121
+ size: 16,
122
+ fill: theme.ink,
123
+ anchor: 'middle',
124
+ weight: 700,
125
+ }),
126
+ );
127
+ }
128
+ if (options.centerLabel) {
129
+ parts.push(
130
+ text(options.centerLabel, cx, cy + 12, {
131
+ size: CHART_FONT.axis,
132
+ fill: theme.mutedFg,
133
+ anchor: 'middle',
134
+ }),
135
+ );
136
+ }
137
+ }
138
+
139
+ const legendX = chartWidth + 4;
140
+ const lineHeight = 15;
141
+ const legendTop = cy - (usable.length * lineHeight) / 2 + 8;
142
+
143
+ usable.forEach((slice, index) => {
144
+ const y = legendTop + index * lineHeight;
145
+ const label = truncateToWidth(
146
+ slice.label,
147
+ CHART_FONT.legend,
148
+ maxLabelWidth,
149
+ );
150
+
151
+ parts.push(
152
+ `<rect x="${legendX}" y="${y - 7}" width="8" height="8" rx="2" fill="${chartColor(theme, index, slice.color)}" />`,
153
+ text(label, legendX + 12, y, { size: CHART_FONT.legend, fill: theme.ink }),
154
+ text(valueLabels[index] ?? '', width, y, {
155
+ size: CHART_FONT.legend,
156
+ fill: theme.mutedFg,
157
+ anchor: 'end',
158
+ }),
159
+ );
160
+ });
161
+
162
+ return svgRoot(width, height, parts.join(''), options.ariaLabel);
163
+ }
@@ -0,0 +1,94 @@
1
+ import { SafeSvg } from '../pdf-document.types';
2
+ import {
3
+ CHART_DEFAULT_HEIGHT,
4
+ CHART_FONT,
5
+ CHART_FULL_WIDTH,
6
+ ChartBaseOptions,
7
+ chartColor,
8
+ emptyChart,
9
+ svgRoot,
10
+ text,
11
+ } from './chart-primitives';
12
+ import { truncateToWidth } from './chart-scale';
13
+
14
+ /**
15
+ * Arcos concêntricos de percentual — a figura de "meu progresso por curso" da
16
+ * visão do aluno. Cada item é um anel; o arco completo é 100%.
17
+ */
18
+ export function gaugeChartSvg(
19
+ options: ChartBaseOptions & {
20
+ items: Array<{ label: string; pct: number; color?: string }>;
21
+ centerLabel?: string;
22
+ },
23
+ ): SafeSvg {
24
+ const {
25
+ theme,
26
+ items,
27
+ width = CHART_FULL_WIDTH,
28
+ height = CHART_DEFAULT_HEIGHT,
29
+ } = options;
30
+
31
+ const usable = items.filter((item) => Number.isFinite(item.pct)).slice(0, 6);
32
+ if (usable.length === 0) return emptyChart(options);
33
+
34
+ const legendWidth = Math.min(width * 0.45, 240);
35
+ const chartWidth = width - legendWidth;
36
+ const cx = chartWidth / 2;
37
+ const cy = height / 2;
38
+
39
+ const outer = Math.min(chartWidth, height) / 2 - 8;
40
+ const thickness = Math.max((outer - 18) / usable.length, 6);
41
+ const parts: string[] = [];
42
+
43
+ usable.forEach((item, index) => {
44
+ const radius = outer - index * thickness - thickness / 2;
45
+ if (radius <= 2) return;
46
+
47
+ const color = chartColor(theme, index, item.color);
48
+ const circumference = 2 * Math.PI * radius;
49
+ const pct = Math.min(Math.max(item.pct, 0), 100);
50
+
51
+ parts.push(
52
+ // Trilho + arco, com o traço girado para começar às 12h.
53
+ `<circle cx="${cx}" cy="${cy}" r="${radius.toFixed(1)}" fill="none" stroke="${theme.mutedBg}" ` +
54
+ `stroke-width="${(thickness - 2).toFixed(1)}" />`,
55
+ `<circle cx="${cx}" cy="${cy}" r="${radius.toFixed(1)}" fill="none" stroke="${color}" ` +
56
+ `stroke-width="${(thickness - 2).toFixed(1)}" stroke-linecap="round" ` +
57
+ `stroke-dasharray="${((circumference * pct) / 100).toFixed(1)} ${circumference.toFixed(1)}" ` +
58
+ `transform="rotate(-90 ${cx} ${cy})" />`,
59
+ );
60
+ });
61
+
62
+ if (options.centerLabel) {
63
+ parts.push(
64
+ text(options.centerLabel, cx, cy + 3, {
65
+ size: CHART_FONT.axis,
66
+ fill: theme.mutedFg,
67
+ anchor: 'middle',
68
+ }),
69
+ );
70
+ }
71
+
72
+ const legendX = chartWidth + 4;
73
+ const lineHeight = 16;
74
+ const legendTop = cy - (usable.length * lineHeight) / 2 + 8;
75
+
76
+ usable.forEach((item, index) => {
77
+ const y = legendTop + index * lineHeight;
78
+
79
+ parts.push(
80
+ `<rect x="${legendX}" y="${y - 7}" width="8" height="8" rx="2" fill="${chartColor(theme, index, item.color)}" />`,
81
+ text(truncateToWidth(item.label, CHART_FONT.legend, legendWidth - 60), legendX + 12, y, {
82
+ size: CHART_FONT.legend,
83
+ fill: theme.ink,
84
+ }),
85
+ text(`${Math.round(item.pct)}%`, width, y, {
86
+ size: CHART_FONT.legend,
87
+ fill: theme.mutedFg,
88
+ anchor: 'end',
89
+ }),
90
+ );
91
+ });
92
+
93
+ return svgRoot(width, height, parts.join(''), options.ariaLabel);
94
+ }
@@ -0,0 +1,159 @@
1
+ import { SafeSvg } from '../pdf-document.types';
2
+ import {
3
+ CHART_DEFAULT_HEIGHT,
4
+ CHART_FONT,
5
+ CHART_FULL_WIDTH,
6
+ ChartBaseOptions,
7
+ ChartSeries,
8
+ chartColor,
9
+ emptyChart,
10
+ legendRows,
11
+ line,
12
+ svgRoot,
13
+ text,
14
+ xAxisLabels,
15
+ yAxis,
16
+ } from './chart-primitives';
17
+ import {
18
+ axisLabelPlan,
19
+ bandScale,
20
+ formatNumber,
21
+ linearScale,
22
+ niceScale,
23
+ withAlpha,
24
+ } from './chart-scale';
25
+
26
+ export interface LineChartOptions extends ChartBaseOptions {
27
+ categories: string[];
28
+ series: ChartSeries[];
29
+ /** Preenche a área sob a linha. */
30
+ area?: boolean;
31
+ yMin?: number;
32
+ yMax?: number;
33
+ yTicks?: number;
34
+ /** Linha tracejada de meta (ex.: frequência de 80%). */
35
+ referenceLine?: { value: number; label?: string };
36
+ showDots?: boolean;
37
+ legend?: boolean;
38
+ }
39
+
40
+ /**
41
+ * Linha (ou área) ao longo de categorias.
42
+ *
43
+ * Buraco na série vira interrupção da linha, não zero: um mês sem dado e um mês
44
+ * com zero matrículas são coisas diferentes, e emendar os dois inventa uma
45
+ * queda que não houve.
46
+ */
47
+ export function lineChartSvg(options: LineChartOptions): SafeSvg {
48
+ const {
49
+ theme,
50
+ categories,
51
+ series,
52
+ width = CHART_FULL_WIDTH,
53
+ height = CHART_DEFAULT_HEIGHT,
54
+ valueFormat = (value: number) => formatNumber(value),
55
+ } = options;
56
+
57
+ const hasData = series.some((s) => s.values.some((v) => typeof v === 'number' && Number.isFinite(v)));
58
+ if (categories.length === 0 || !hasData) return emptyChart(options);
59
+
60
+ const showLegend = options.legend ?? series.length > 1;
61
+ const legendHeight = showLegend ? 16 : 0;
62
+ const plot = { left: 42, right: width - 8, top: 10, bottom: height - 26 - legendHeight };
63
+
64
+ const values = series
65
+ .flatMap((s) => s.values)
66
+ .filter((v): v is number => typeof v === 'number' && Number.isFinite(v));
67
+ const reference = options.referenceLine?.value;
68
+
69
+ const scale = niceScale(
70
+ options.yMin ?? Math.min(0, ...values),
71
+ options.yMax ?? Math.max(...values, reference ?? Number.NEGATIVE_INFINITY),
72
+ options.yTicks ?? 4,
73
+ );
74
+ const y = linearScale([scale.min, scale.max], [plot.bottom, plot.top]);
75
+ const band = bandScale(categories.length, [plot.left, plot.right], 0);
76
+
77
+ const parts: string[] = [
78
+ yAxis(scale.ticks, y, plot, theme, valueFormat),
79
+ line(plot.left, plot.bottom, plot.right, plot.bottom, theme.mutedFg),
80
+ ];
81
+
82
+ if (typeof reference === 'number') {
83
+ parts.push(
84
+ line(plot.left, y(reference), plot.right, y(reference), theme.mutedFg, { dash: '4 3' }),
85
+ text(options.referenceLine?.label ?? valueFormat(reference), plot.right, y(reference) - 4, {
86
+ size: CHART_FONT.axis,
87
+ fill: theme.mutedFg,
88
+ anchor: 'end',
89
+ }),
90
+ );
91
+ }
92
+
93
+ const showDots = options.showDots ?? categories.length <= 24;
94
+
95
+ series.forEach((serie, serieIndex) => {
96
+ const color = chartColor(theme, serieIndex, serie.color);
97
+ const segments: Array<Array<{ x: number; y: number }>> = [];
98
+ let current: Array<{ x: number; y: number }> = [];
99
+
100
+ categories.forEach((_, index) => {
101
+ const raw = serie.values[index];
102
+ if (typeof raw !== 'number' || !Number.isFinite(raw)) {
103
+ if (current.length) segments.push(current);
104
+ current = [];
105
+ return;
106
+ }
107
+ current.push({ x: band.center(index), y: y(raw) });
108
+ });
109
+ if (current.length) segments.push(current);
110
+
111
+ segments.forEach((points) => {
112
+ const path = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
113
+
114
+ if (options.area && points.length > 1) {
115
+ const first = points[0]!;
116
+ const last = points[points.length - 1]!;
117
+ parts.push(
118
+ `<path d="${path} L${last.x.toFixed(1)},${plot.bottom} L${first.x.toFixed(1)},${plot.bottom} Z" ` +
119
+ `fill="${withAlpha(color, 0.18)}" stroke="none" />`,
120
+ );
121
+ }
122
+
123
+ parts.push(
124
+ `<path d="${path}" fill="none" stroke="${color}" stroke-width="1.8" ` +
125
+ 'stroke-linejoin="round" stroke-linecap="round" />',
126
+ );
127
+
128
+ if (showDots) {
129
+ points.forEach((point) => {
130
+ parts.push(
131
+ `<circle cx="${point.x.toFixed(1)}" cy="${point.y.toFixed(1)}" r="2.2" fill="${color}" />`,
132
+ );
133
+ });
134
+ }
135
+ });
136
+ });
137
+
138
+ const plan = axisLabelPlan(categories, band.bandwidth, CHART_FONT.axis);
139
+ parts.push(xAxisLabels(categories, band.center, plot.bottom + 12, plan, band.bandwidth, theme));
140
+
141
+ if (showLegend) {
142
+ const legend = legendRows(
143
+ series.map((serie, index) => ({
144
+ label: serie.name,
145
+ color: chartColor(theme, index, serie.color),
146
+ })),
147
+ width,
148
+ height - 4,
149
+ theme,
150
+ );
151
+ parts.push(legend.markup);
152
+ }
153
+
154
+ return svgRoot(width, height, parts.join(''), options.ariaLabel);
155
+ }
156
+
157
+ /** Mesma figura com a área preenchida — o nome existe para o chamador ler melhor. */
158
+ export const areaChartSvg = (options: LineChartOptions): SafeSvg =>
159
+ lineChartSvg({ ...options, area: true });