@harbortouch/skytab-analytics-report-utils 0.38.1 → 0.40.0

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.
@@ -1,5 +1,7 @@
1
1
  // src/exporters/pdf/index.ts
2
2
  import PDFDocument from "pdfkit";
3
+ import { join } from "path";
4
+ import { fileURLToPath } from "url";
3
5
 
4
6
  // src/exporters/pdf/pdf.utils.ts
5
7
  var sanitizePdfText = (text) => {
@@ -10,38 +12,75 @@ var sanitizePdfText = (text) => {
10
12
  return leadingSpaces + text.trimStart().replace(/€/g, "EUR ").replace(
11
13
  /[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{1F600}-\u{1F64F}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1FA00}-\u{1FA6F}]|[\u{1FA70}-\u{1FAFF}]|[\u{FE00}-\u{FE0F}]|[\u{1F004}]|[\u{1F0CF}]|[\u{1F18E}]|[\u{1F191}-\u{1F19A}]|[\u{1F1E6}-\u{1F1FF}]|[\u{1F201}-\u{1F251}]/gu,
12
14
  ""
13
- ).replace(/[\x00-\x1F\x7F]/g, " ").replace(/[^ -ÿ]/g, "").replace(/\s+/g, " ").trim();
15
+ ).replace(/[\x00-\x1F\x7F-\x9F]/g, " ").replace(/ /g, " ").replace(/[^\x20-\x7E\xA1-\xFF]/g, "").replace(/\s+/g, " ").trim();
14
16
  };
15
17
 
18
+ // src/exporters/pdf/constants.ts
19
+ var ZOOM_FACTOR = 1.44;
20
+ var FONT_REGULAR = "Lato";
21
+ var FONT_BOLD = "Lato-Bold";
22
+ var DEFAULT_TEXT_COLOR = "#000000";
23
+ var HEADER_BACKGROUND_COLOR = "#f9fafb";
24
+ var ZEBRA_BACKGROUND_COLOR = "#fafbfc";
25
+ var SEPARATOR_COLOR = "#E4E5E5";
26
+ var SECTION_TITLE_FONT_SIZE = 1.17 * 6 * ZOOM_FACTOR;
27
+ var DEFAULT_FONT_SIZE = 5 * ZOOM_FACTOR;
28
+ var BODY_FONT_SIZE = 6 * ZOOM_FACTOR;
29
+ var TABLE_CELL_PADDING = 4;
30
+ var DEFAULT_ROW_HEIGHT = 25;
31
+ var MIN_COLUMN_WIDTH = 35;
32
+ var MARGINS = { top: 30, bottom: 30, left: 30, right: 30 };
33
+ var PAGE_SIZE = "LETTER";
34
+ var PAGE_LAYOUT = "landscape";
35
+ var SECTION_GAP = 18;
36
+ var MIN_SECTION_HEIGHT = 75;
37
+ var HEADER_RULE_COLOR = "#000000";
38
+ var HEADER_RULE_WIDTH = 1.5;
39
+ var HEADER_SPACING_FACTOR = 0.7;
40
+
16
41
  // src/exporters/pdf/index.ts
17
- var FONT_BOLD = "Helvetica-Bold";
18
- var FONT_REGULAR = "Helvetica";
19
- var COLOR_BLACK = "#000000";
20
- var COLOR_HEADER_BACKGROUND = "#D3D3D3";
21
- var COLOR_ROW_BACKGROUND_ALTERNATE = "#F5F5F5";
42
+ var FONTS_DIR = fileURLToPath(new URL("./fonts", import.meta.url));
22
43
  var PdfExporter = class {
44
+ registerFonts(doc) {
45
+ doc.registerFont(FONT_REGULAR, join(FONTS_DIR, "Lato-Regular.ttf"));
46
+ doc.registerFont(FONT_BOLD, join(FONTS_DIR, "Lato-Bold.ttf"));
47
+ }
23
48
  generate(content) {
24
- const doc = new PDFDocument({ margin: 30, size: "A4", layout: "landscape" });
49
+ const doc = new PDFDocument({
50
+ margin: MARGINS.top,
51
+ size: PAGE_SIZE,
52
+ layout: PAGE_LAYOUT
53
+ });
54
+ this.registerFonts(doc);
25
55
  const buffers = [];
26
56
  return new Promise((resolve, reject) => {
27
57
  doc.on("data", (chunk) => buffers.push(chunk));
28
58
  doc.on("end", () => resolve(Buffer.concat(buffers)));
29
59
  doc.on("error", reject);
30
60
  try {
61
+ const pageBottom = doc.page.height - doc.page.margins.bottom;
62
+ let currentY = doc.page.margins.top;
31
63
  content.sections.forEach((section, index) => {
32
- const sanitizedSections = this.sanitizeSections(section);
64
+ const sanitized = this.sanitizeSections(section);
33
65
  if (index > 0) {
34
- doc.addPage();
66
+ if (currentY + SECTION_GAP + MIN_SECTION_HEIGHT > pageBottom) {
67
+ doc.addPage();
68
+ currentY = doc.page.margins.top;
69
+ } else {
70
+ currentY += SECTION_GAP;
71
+ }
72
+ }
73
+ if (sanitized.filters && sanitized.filters.length > 0) {
74
+ currentY = this.drawFilters(doc, sanitized.filters, currentY);
35
75
  }
36
- let currentY = doc.y;
37
- if (sanitizedSections.title) {
38
- currentY = this.drawSectionTitle(doc, sanitizedSections.title);
76
+ if (sanitized.appliedFilters !== void 0) {
77
+ currentY = this.drawAppliedFilters(doc, sanitized.appliedFilters, currentY);
39
78
  }
40
- if (sanitizedSections.filters && sanitizedSections.filters.length > 0) {
41
- currentY = this.drawFilters(doc, sanitizedSections.filters);
79
+ if (sanitized.title && sanitized.table) {
80
+ currentY = this.drawSectionTitle(doc, sanitized.title, currentY);
42
81
  }
43
- if (sanitizedSections.table) {
44
- this.drawTable(doc, sanitizedSections.table, currentY);
82
+ if (sanitized.table) {
83
+ currentY = this.drawTable(doc, sanitized.table, currentY);
45
84
  }
46
85
  });
47
86
  doc.end();
@@ -50,21 +89,27 @@ var PdfExporter = class {
50
89
  }
51
90
  });
52
91
  }
53
- sanitizeSections(sections) {
54
- const sanitized = { title: sanitizePdfText(sections.title) };
55
- if (sections.filters) {
56
- sanitized.filters = sections.filters.map((filter) => ({
92
+ sanitizeSections(section) {
93
+ const sanitized = { title: sanitizePdfText(section.title) };
94
+ if (section.filters) {
95
+ sanitized.filters = section.filters.map((filter) => ({
96
+ label: sanitizePdfText(filter.label),
97
+ value: sanitizePdfText(filter.value)
98
+ }));
99
+ }
100
+ if (section.appliedFilters !== void 0) {
101
+ sanitized.appliedFilters = section.appliedFilters.map((filter) => ({
57
102
  label: sanitizePdfText(filter.label),
58
103
  value: sanitizePdfText(filter.value)
59
104
  }));
60
105
  }
61
- if (sections.table) {
106
+ if (section.table) {
62
107
  sanitized.table = {
63
- columns: sections.table.columns.map((col) => ({
108
+ columns: section.table.columns.map((col) => ({
64
109
  ...col,
65
110
  header: sanitizePdfText(col.header)
66
111
  })),
67
- rows: sections.table.rows.map((row) => {
112
+ rows: section.table.rows.map((row) => {
68
113
  const sanitizedRow = {};
69
114
  for (const [key, value] of Object.entries(row)) {
70
115
  sanitizedRow[key] = typeof value === "string" ? sanitizePdfText(value) : value;
@@ -75,26 +120,45 @@ var PdfExporter = class {
75
120
  }
76
121
  return sanitized;
77
122
  }
78
- drawSectionTitle(doc, title) {
79
- doc.fillColor(COLOR_BLACK).font(FONT_BOLD).fontSize(13).text(title, { align: "left" });
80
- doc.moveDown(0.5);
81
- return doc.y;
123
+ drawSectionTitle(doc, title, startY) {
124
+ const left = doc.page.margins.left;
125
+ const usableWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
126
+ doc.fillColor(DEFAULT_TEXT_COLOR).font(FONT_REGULAR).fontSize(SECTION_TITLE_FONT_SIZE).text(title, left, startY, { align: "left", width: usableWidth });
127
+ const spacing = SECTION_TITLE_FONT_SIZE * HEADER_SPACING_FACTOR;
128
+ const ruleY = doc.y + spacing;
129
+ doc.strokeColor(HEADER_RULE_COLOR).lineWidth(HEADER_RULE_WIDTH).moveTo(left, ruleY).lineTo(left + usableWidth, ruleY).stroke();
130
+ return ruleY + spacing;
82
131
  }
83
- drawFilters(doc, filters) {
84
- doc.fillColor(COLOR_BLACK).font(FONT_BOLD).fontSize(11).text("Applied Filters", { align: "left" });
85
- doc.moveDown(0.5);
86
- filters.forEach((filter) => {
87
- doc.font(FONT_BOLD).fontSize(11).text(filter.label, { continued: true });
132
+ drawFilters(doc, filters, startY) {
133
+ const left = doc.page.margins.left;
134
+ doc.fillColor(DEFAULT_TEXT_COLOR).font(FONT_BOLD).fontSize(BODY_FONT_SIZE);
135
+ filters.forEach((filter, i) => {
136
+ if (i === 0) {
137
+ doc.text(filter.label, left, startY, { continued: true });
138
+ } else {
139
+ doc.font(FONT_BOLD).fontSize(BODY_FONT_SIZE).text(filter.label, { continued: true });
140
+ }
88
141
  doc.font(FONT_REGULAR).text(`: ${filter.value}`);
89
142
  });
143
+ doc.moveDown(0.5);
144
+ return doc.y;
145
+ }
146
+ drawAppliedFilters(doc, filters, startY) {
147
+ doc.fillColor(DEFAULT_TEXT_COLOR).font(FONT_BOLD).fontSize(BODY_FONT_SIZE).text("Applied Filters:", doc.page.margins.left, startY, { continued: filters.length === 0 });
148
+ if (filters.length === 0) {
149
+ doc.font(FONT_REGULAR).text(" None");
150
+ } else {
151
+ filters.forEach((filter) => {
152
+ doc.font(FONT_BOLD).fontSize(BODY_FONT_SIZE).text(filter.label, { continued: true });
153
+ doc.font(FONT_REGULAR).text(`: ${filter.value}`);
154
+ });
155
+ }
90
156
  doc.moveDown(1);
91
157
  return doc.y;
92
158
  }
93
- drawTable(doc, table, startY = doc.y) {
159
+ drawTable(doc, table, startY) {
94
160
  const columns = table.columns.length > 0 ? table.columns.map((c) => ({ ...c })) : [{ header: "No data", key: "no_data", width: 100 }];
95
161
  const rows = table.rows ?? [];
96
- const baseCellPadding = 4;
97
- const minColumnWidth = 35;
98
162
  const columnCount = columns.length;
99
163
  const headerFontSize = columnCount > 13 ? 8 : columnCount > 10 ? 9 : columnCount > 7 ? 10 : 11;
100
164
  const dataFontSize = columnCount > 13 ? 7 : columnCount > 10 ? 8 : columnCount > 7 ? 9 : 10;
@@ -115,19 +179,26 @@ var PdfExporter = class {
115
179
  }
116
180
  });
117
181
  const contentWidth = Math.max(headerTextWidth, maxDataWidth);
118
- col.width = Math.max(contentWidth + baseCellPadding * 2 + headerWidthSlack, minColumnWidth);
119
- headerMinWidths.set(col, headerMinWidth + baseCellPadding * 2 + headerWidthSlack);
182
+ col.width = Math.max(contentWidth + TABLE_CELL_PADDING * 2 + headerWidthSlack, MIN_COLUMN_WIDTH);
183
+ headerMinWidths.set(col, headerMinWidth + TABLE_CELL_PADDING * 2 + headerWidthSlack);
120
184
  });
121
185
  const usableWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
122
- const totalColumnWidth = columns.reduce((sum, c) => sum + (c.width ?? minColumnWidth), 0);
186
+ const totalColumnWidth = columns.reduce((sum, c) => sum + (c.width ?? MIN_COLUMN_WIDTH), 0);
123
187
  if (totalColumnWidth > usableWidth) {
124
188
  const scale = usableWidth / totalColumnWidth;
125
189
  columns.forEach((c) => {
126
- const headerMin = headerMinWidths.get(c) ?? minColumnWidth;
127
- c.width = Math.max(Math.floor((c.width ?? minColumnWidth) * scale), headerMin, minColumnWidth);
190
+ const headerMin = headerMinWidths.get(c) ?? MIN_COLUMN_WIDTH;
191
+ c.width = Math.max(Math.floor((c.width ?? MIN_COLUMN_WIDTH) * scale), headerMin, MIN_COLUMN_WIDTH);
192
+ });
193
+ } else if (totalColumnWidth < usableWidth) {
194
+ const scale = usableWidth / totalColumnWidth;
195
+ columns.forEach((c) => {
196
+ c.width = Math.floor((c.width ?? MIN_COLUMN_WIDTH) * scale);
128
197
  });
198
+ const expanded = columns.reduce((sum, c) => sum + (c.width ?? 0), 0);
199
+ columns[columns.length - 1].width = (columns[columns.length - 1].width ?? 0) + (usableWidth - expanded);
129
200
  }
130
- const tableWidth = columns.reduce((sum, c) => sum + (c.width ?? minColumnWidth), 0);
201
+ const tableWidth = columns.reduce((sum, c) => sum + (c.width ?? MIN_COLUMN_WIDTH), 0);
131
202
  const pageBottom = doc.page.height - doc.page.margins.bottom;
132
203
  const calculateRowHeight = (row, isHeader = false) => {
133
204
  const fontSize = isHeader ? headerFontSize : dataFontSize;
@@ -136,17 +207,22 @@ var PdfExporter = class {
136
207
  columns.forEach((col) => {
137
208
  const cellValue = isHeader ? col.header : row?.[col.key] !== void 0 ? String(row[col.key]) : "";
138
209
  const textWidth = doc.widthOfString(cellValue);
139
- const availableWidth = (col.width ?? minColumnWidth) - baseCellPadding * 2;
210
+ const availableWidth = (col.width ?? MIN_COLUMN_WIDTH) - TABLE_CELL_PADDING * 2;
140
211
  const lines = Math.ceil(textWidth / availableWidth);
141
212
  if (lines > maxLines) {
142
213
  maxLines = lines;
143
214
  }
144
215
  });
145
- return Math.max(maxLines * (fontSize + 2) + baseCellPadding * 2, 25);
216
+ return Math.max(maxLines * (fontSize + 2) + TABLE_CELL_PADDING * 2, DEFAULT_ROW_HEIGHT);
146
217
  };
147
218
  const headerHeight = calculateRowHeight(null, true);
148
- const drawTableHeader = (y) => this.drawHeader(doc, columns, y, tableWidth, headerHeight, baseCellPadding, headerFontSize, minColumnWidth);
149
- let currentY = drawTableHeader(startY);
219
+ const drawTableHeader = (y) => this.drawHeader(doc, columns, y, tableWidth, headerHeight, TABLE_CELL_PADDING, headerFontSize, MIN_COLUMN_WIDTH);
220
+ let tableStartY = startY;
221
+ if (tableStartY + headerHeight > pageBottom) {
222
+ doc.addPage();
223
+ tableStartY = doc.page.margins.top;
224
+ }
225
+ let currentY = drawTableHeader(tableStartY);
150
226
  doc.font(FONT_REGULAR).fontSize(dataFontSize);
151
227
  rows.forEach((row, rowIndex) => {
152
228
  const currentRowHeight = calculateRowHeight(row, false);
@@ -158,56 +234,68 @@ var PdfExporter = class {
158
234
  currentY = this.drawRow(
159
235
  doc,
160
236
  row,
161
- rowIndex,
162
237
  columns,
163
238
  currentY,
164
- tableWidth,
165
239
  currentRowHeight,
166
- baseCellPadding,
240
+ TABLE_CELL_PADDING,
167
241
  dataFontSize,
168
- minColumnWidth
242
+ MIN_COLUMN_WIDTH,
243
+ rowIndex
169
244
  );
170
245
  });
246
+ const left = doc.page.margins.left;
247
+ const closingRuleY = currentY + SECTION_TITLE_FONT_SIZE * HEADER_SPACING_FACTOR;
248
+ doc.strokeColor(HEADER_RULE_COLOR).lineWidth(HEADER_RULE_WIDTH).moveTo(left, closingRuleY).lineTo(left + tableWidth, closingRuleY).stroke();
249
+ return closingRuleY;
171
250
  }
172
251
  drawHeader(doc, columns, startY, tableWidth, headerHeight, cellPadding, headerFontSize, minColumnWidth) {
173
- doc.strokeColor(COLOR_BLACK).lineWidth(0.5).moveTo(doc.page.margins.left, startY).lineTo(doc.page.margins.left + tableWidth, startY).stroke();
174
- doc.fillColor(COLOR_HEADER_BACKGROUND).rect(doc.page.margins.left, startY, tableWidth, headerHeight).fill();
175
- let xPosition = doc.page.margins.left;
176
- doc.fillColor(COLOR_BLACK).font(FONT_BOLD).fontSize(headerFontSize);
252
+ const left = doc.page.margins.left;
253
+ doc.rect(left, startY, tableWidth, headerHeight).fill(HEADER_BACKGROUND_COLOR);
254
+ doc.strokeColor(SEPARATOR_COLOR).lineWidth(0.5).moveTo(left, startY).lineTo(left + tableWidth, startY).stroke();
255
+ let xPosition = left;
256
+ doc.fillColor(DEFAULT_TEXT_COLOR).font(FONT_BOLD).fontSize(headerFontSize);
257
+ const headerLineHeight = doc.currentLineHeight(true);
258
+ const headerTextY = startY + Math.max((headerHeight - headerLineHeight) / 2, 0);
177
259
  columns.forEach((col) => {
178
- doc.strokeColor(COLOR_BLACK).lineWidth(0.5).moveTo(xPosition, startY).lineTo(xPosition, startY + headerHeight).stroke();
179
- doc.text(col.header, xPosition + cellPadding, startY + cellPadding, {
260
+ doc.text(col.header, xPosition + cellPadding, headerTextY, {
180
261
  width: (col.width ?? minColumnWidth) - cellPadding * 2,
262
+ height: headerLineHeight,
181
263
  align: "left",
182
- lineBreak: false
264
+ lineBreak: false,
265
+ ellipsis: true
183
266
  });
184
267
  xPosition += col.width ?? minColumnWidth;
185
268
  });
186
- doc.moveTo(xPosition, startY).lineTo(xPosition, startY + headerHeight).stroke();
187
- doc.moveTo(doc.page.margins.left, startY + headerHeight).lineTo(xPosition, startY + headerHeight).stroke();
269
+ doc.strokeColor(SEPARATOR_COLOR).lineWidth(0.5).moveTo(left, startY + headerHeight).lineTo(left + tableWidth, startY + headerHeight).stroke();
188
270
  return startY + headerHeight;
189
271
  }
190
- drawRow(doc, row, rowIndex, columns, currentY, tableWidth, rowHeight, cellPadding, dataFontSize, minColumnWidth) {
191
- doc.font(FONT_REGULAR).fontSize(dataFontSize);
192
- if (rowIndex % 2 === 0) {
193
- doc.fillColor(COLOR_ROW_BACKGROUND_ALTERNATE).rect(doc.page.margins.left, currentY, tableWidth, rowHeight).fill();
272
+ drawRow(doc, row, columns, currentY, rowHeight, cellPadding, dataFontSize, minColumnWidth, rowIndex = 0) {
273
+ const left = doc.page.margins.left;
274
+ const tableWidth = columns.reduce((sum, c) => sum + (c.width ?? minColumnWidth), 0);
275
+ if (rowIndex % 2 === 1) {
276
+ doc.rect(left, currentY, tableWidth, rowHeight).fill(ZEBRA_BACKGROUND_COLOR);
194
277
  }
195
- let xPosition = doc.page.margins.left;
196
- doc.fillColor(COLOR_BLACK);
197
- columns.forEach((col) => {
198
- doc.strokeColor(COLOR_BLACK).lineWidth(0.5).moveTo(xPosition, currentY).lineTo(xPosition, currentY + rowHeight).stroke();
278
+ doc.font(FONT_REGULAR).fontSize(dataFontSize).fillColor(DEFAULT_TEXT_COLOR);
279
+ const dataLineHeight = doc.currentLineHeight(true);
280
+ const dataTextY = currentY + Math.max((rowHeight - dataLineHeight) / 2, 0);
281
+ let xPosition = left;
282
+ columns.forEach((col, i) => {
199
283
  const rawValue = row[col.key];
200
284
  const cellValue = typeof rawValue === "string" || typeof rawValue === "number" || typeof rawValue === "boolean" ? String(rawValue) : "";
201
285
  const isNumeric = typeof rawValue === "number" || typeof rawValue === "string" && rawValue !== "" && !isNaN(Number(rawValue));
202
- doc.text(cellValue, xPosition + cellPadding, currentY + cellPadding, {
286
+ doc.text(cellValue, xPosition + cellPadding, dataTextY, {
203
287
  width: (col.width ?? minColumnWidth) - cellPadding * 2,
288
+ height: dataLineHeight,
204
289
  align: isNumeric ? "right" : "left",
205
- lineBreak: false
290
+ lineBreak: false,
291
+ ellipsis: true
206
292
  });
207
293
  xPosition += col.width ?? minColumnWidth;
294
+ if (i < columns.length - 1) {
295
+ doc.strokeColor(SEPARATOR_COLOR).lineWidth(0.5).moveTo(xPosition, currentY).lineTo(xPosition, currentY + rowHeight).stroke();
296
+ }
208
297
  });
209
- doc.moveTo(xPosition, currentY).lineTo(xPosition, currentY + rowHeight).stroke();
210
- doc.moveTo(doc.page.margins.left, currentY + rowHeight).lineTo(xPosition, currentY + rowHeight).stroke();
298
+ doc.strokeColor(SEPARATOR_COLOR).lineWidth(0.5).moveTo(left, currentY + rowHeight).lineTo(left + tableWidth, currentY + rowHeight).stroke();
211
299
  return currentY + rowHeight;
212
300
  }
213
301
  };