@harbortouch/skytab-analytics-report-utils 0.16.0 → 0.17.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.
- package/README.md +73 -0
- package/dist/pdf/index.cjs +235 -0
- package/dist/pdf/index.d.cts +29 -0
- package/dist/pdf/index.d.ts +29 -0
- package/dist/pdf/index.js +200 -0
- package/package.json +10 -2
package/README.md
CHANGED
|
@@ -41,6 +41,76 @@ import {
|
|
|
41
41
|
} from '@harbortouch/skytab-analytics-report-utils';
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
### Exporting report data to PDF
|
|
45
|
+
|
|
46
|
+
Import from the `/pdf` sub-path. This keeps `pdfkit` out of frontend bundles — it is only included when this import is actually used.
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
import { PdfExporter } from '@harbortouch/skytab-analytics-report-utils/pdf';
|
|
50
|
+
|
|
51
|
+
const exporter = new PdfExporter();
|
|
52
|
+
|
|
53
|
+
const buffer = await exporter.generate({
|
|
54
|
+
pages: [
|
|
55
|
+
{
|
|
56
|
+
filters: [
|
|
57
|
+
{ label: 'Date Range', value: 'Jan 1 – Jan 31' },
|
|
58
|
+
{ label: 'Location', value: 'Downtown' },
|
|
59
|
+
],
|
|
60
|
+
table: {
|
|
61
|
+
columns: [
|
|
62
|
+
{ header: 'Item', key: 'name' },
|
|
63
|
+
{ header: 'Net Sales', key: 'netSales' },
|
|
64
|
+
{ header: 'Qty', key: 'qty' },
|
|
65
|
+
],
|
|
66
|
+
rows: reportRows,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// buffer is a Node.js Buffer containing the PDF binary
|
|
73
|
+
fs.writeFileSync('report.pdf', buffer);
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`PdfExporter.generate()` returns a `Promise<Buffer>`. Each entry in `pages` produces one PDF page (additional pages are added automatically when table rows overflow).
|
|
77
|
+
|
|
78
|
+
**`PdfPage`**
|
|
79
|
+
|
|
80
|
+
| Field | Type | Description |
|
|
81
|
+
| --------- | ------------- | ---------------------------------------------- |
|
|
82
|
+
| `filters` | `PdfFilter[]` | Optional applied-filters block above the table |
|
|
83
|
+
| `table` | `PdfTable` | Optional table to render on the page |
|
|
84
|
+
|
|
85
|
+
**`PdfTable`**
|
|
86
|
+
|
|
87
|
+
| Field | Type | Description |
|
|
88
|
+
| --------- | --------------------------- | ---------------------------------------- |
|
|
89
|
+
| `columns` | `PdfTableColumn[]` | Column definitions (order preserved) |
|
|
90
|
+
| `rows` | `Record<string, unknown>[]` | Row data — keyed by `PdfTableColumn.key` |
|
|
91
|
+
|
|
92
|
+
**`PdfTableColumn`**
|
|
93
|
+
|
|
94
|
+
| Field | Type | Description |
|
|
95
|
+
| -------- | -------- | ------------------------------------------------------- |
|
|
96
|
+
| `header` | `string` | Column header label |
|
|
97
|
+
| `key` | `string` | Field name used to look up the value in each row object |
|
|
98
|
+
| `width` | `number` | Optional hint — auto-sized from content if omitted |
|
|
99
|
+
|
|
100
|
+
**Behaviour notes**
|
|
101
|
+
|
|
102
|
+
- Output is A4 landscape with 30 pt margins.
|
|
103
|
+
- Column widths are auto-calculated from header and cell content; columns are scaled down proportionally if they exceed the usable page width.
|
|
104
|
+
- Font size scales with column count (7–11 pt) so wide tables remain readable.
|
|
105
|
+
- Numeric cell values are right-aligned; text values are left-aligned.
|
|
106
|
+
- Alternating rows use a light grey background (`#F5F5F5`); the header row uses `#D3D3D3`.
|
|
107
|
+
- Emojis are stripped from all text automatically (pdfkit's built-in fonts do not support them).
|
|
108
|
+
- When a table spans multiple pages the header row is repeated at the top of each new page.
|
|
109
|
+
|
|
110
|
+
> **Note:** `pdfkit` is a dependency of this library — consuming projects do not need to install it separately.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
44
114
|
### Exporting report data to CSV
|
|
45
115
|
|
|
46
116
|
Import from the `/csv` sub-path. This keeps `csv-stringify` out of frontend bundles — it is only included when this import is actually used.
|
|
@@ -93,6 +163,9 @@ src/
|
|
|
93
163
|
│ └── index.ts — ReportColumnKey union type + COLUMN_REGISTRY (i18n keys)
|
|
94
164
|
├── csv/
|
|
95
165
|
│ └── index.ts — CsvExporter — published as the ./csv sub-path export
|
|
166
|
+
├── pdf/
|
|
167
|
+
│ ├── index.ts — PdfExporter — published as the ./pdf sub-path export
|
|
168
|
+
│ └── pdf.utils.ts — stripEmojis() helper
|
|
96
169
|
├── reports/
|
|
97
170
|
│ ├── salesSummary.ts
|
|
98
171
|
│ ├── ticketSummary.ts
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/pdf/index.ts
|
|
31
|
+
var pdf_exports = {};
|
|
32
|
+
__export(pdf_exports, {
|
|
33
|
+
PdfExporter: () => PdfExporter
|
|
34
|
+
});
|
|
35
|
+
module.exports = __toCommonJS(pdf_exports);
|
|
36
|
+
var import_pdfkit = __toESM(require("pdfkit"), 1);
|
|
37
|
+
|
|
38
|
+
// src/pdf/pdf.utils.ts
|
|
39
|
+
var stripEmojis = (text) => {
|
|
40
|
+
if (!text) {
|
|
41
|
+
return text;
|
|
42
|
+
}
|
|
43
|
+
return text.replace(
|
|
44
|
+
/[\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,
|
|
45
|
+
""
|
|
46
|
+
).replace(/\s+/g, " ").trim();
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// src/pdf/index.ts
|
|
50
|
+
var FONT_BOLD = "Helvetica-Bold";
|
|
51
|
+
var FONT_REGULAR = "Helvetica";
|
|
52
|
+
var COLOR_BLACK = "#000000";
|
|
53
|
+
var COLOR_HEADER_BACKGROUND = "#D3D3D3";
|
|
54
|
+
var COLOR_ROW_BACKGROUND_ALTERNATE = "#F5F5F5";
|
|
55
|
+
var PdfExporter = class {
|
|
56
|
+
generate(content) {
|
|
57
|
+
const doc = new import_pdfkit.default({ margin: 30, size: "A4", layout: "landscape" });
|
|
58
|
+
const buffers = [];
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
doc.on("data", (chunk) => buffers.push(chunk));
|
|
61
|
+
doc.on("end", () => resolve(Buffer.concat(buffers)));
|
|
62
|
+
doc.on("error", reject);
|
|
63
|
+
try {
|
|
64
|
+
content.pages.forEach((section, index) => {
|
|
65
|
+
const sanitizedSections = this.sanitizeSections(section);
|
|
66
|
+
if (index > 0) {
|
|
67
|
+
doc.addPage();
|
|
68
|
+
}
|
|
69
|
+
let currentY = doc.y;
|
|
70
|
+
if (sanitizedSections.filters && sanitizedSections.filters.length > 0) {
|
|
71
|
+
currentY = this.drawFilters(doc, sanitizedSections.filters);
|
|
72
|
+
}
|
|
73
|
+
if (sanitizedSections.table) {
|
|
74
|
+
this.drawTable(doc, sanitizedSections.table, currentY);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
doc.end();
|
|
78
|
+
} catch (err) {
|
|
79
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
sanitizeSections(sections) {
|
|
84
|
+
const sanitized = {};
|
|
85
|
+
if (sections.filters) {
|
|
86
|
+
sanitized.filters = sections.filters.map((filter) => ({
|
|
87
|
+
label: stripEmojis(filter.label),
|
|
88
|
+
value: stripEmojis(filter.value)
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
if (sections.table) {
|
|
92
|
+
sanitized.table = {
|
|
93
|
+
columns: sections.table.columns.map((col) => ({
|
|
94
|
+
...col,
|
|
95
|
+
header: stripEmojis(col.header)
|
|
96
|
+
})),
|
|
97
|
+
rows: sections.table.rows.map((row) => {
|
|
98
|
+
const sanitizedRow = {};
|
|
99
|
+
for (const [key, value] of Object.entries(row)) {
|
|
100
|
+
sanitizedRow[key] = typeof value === "string" ? stripEmojis(value) : value;
|
|
101
|
+
}
|
|
102
|
+
return sanitizedRow;
|
|
103
|
+
})
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return sanitized;
|
|
107
|
+
}
|
|
108
|
+
drawFilters(doc, filters) {
|
|
109
|
+
doc.fillColor(COLOR_BLACK).font(FONT_BOLD).fontSize(11).text("Applied Filters", { align: "left" });
|
|
110
|
+
doc.moveDown(0.5);
|
|
111
|
+
filters.forEach((filter) => {
|
|
112
|
+
doc.font(FONT_BOLD).fontSize(11).text(filter.label, { continued: true });
|
|
113
|
+
doc.font(FONT_REGULAR).text(`: ${filter.value}`);
|
|
114
|
+
});
|
|
115
|
+
doc.moveDown(1);
|
|
116
|
+
return doc.y;
|
|
117
|
+
}
|
|
118
|
+
drawTable(doc, table, startY = doc.y) {
|
|
119
|
+
const columns = table.columns.length > 0 ? table.columns.map((c) => ({ ...c })) : [{ header: "No data", key: "no_data", width: 100 }];
|
|
120
|
+
const rows = table.rows ?? [];
|
|
121
|
+
const baseCellPadding = 4;
|
|
122
|
+
const minColumnWidth = 35;
|
|
123
|
+
const columnCount = columns.length;
|
|
124
|
+
const headerFontSize = columnCount > 13 ? 8 : columnCount > 10 ? 9 : columnCount > 7 ? 10 : 11;
|
|
125
|
+
const dataFontSize = columnCount > 13 ? 7 : columnCount > 10 ? 8 : columnCount > 7 ? 9 : 10;
|
|
126
|
+
columns.forEach((col) => {
|
|
127
|
+
doc.font(FONT_BOLD).fontSize(headerFontSize);
|
|
128
|
+
let maxWidth = doc.widthOfString(col.header);
|
|
129
|
+
doc.font(FONT_REGULAR).fontSize(dataFontSize);
|
|
130
|
+
rows.forEach((row) => {
|
|
131
|
+
const rv = row[col.key];
|
|
132
|
+
const cellValue = typeof rv === "string" || typeof rv === "number" || typeof rv === "boolean" ? String(rv) : "";
|
|
133
|
+
const textWidth = doc.widthOfString(cellValue);
|
|
134
|
+
if (textWidth > maxWidth) {
|
|
135
|
+
maxWidth = textWidth;
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
col.width = Math.max(maxWidth + baseCellPadding * 2, minColumnWidth);
|
|
139
|
+
});
|
|
140
|
+
const usableWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
|
141
|
+
const totalColumnWidth = columns.reduce((sum, c) => sum + (c.width ?? minColumnWidth), 0);
|
|
142
|
+
if (totalColumnWidth > usableWidth) {
|
|
143
|
+
const scale = usableWidth / totalColumnWidth;
|
|
144
|
+
columns.forEach((c) => {
|
|
145
|
+
c.width = Math.max(Math.floor((c.width ?? minColumnWidth) * scale), minColumnWidth);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
const tableWidth = columns.reduce((sum, c) => sum + (c.width ?? minColumnWidth), 0);
|
|
149
|
+
const pageBottom = doc.page.height - doc.page.margins.bottom;
|
|
150
|
+
const calculateRowHeight = (row, isHeader = false) => {
|
|
151
|
+
const fontSize = isHeader ? headerFontSize : dataFontSize;
|
|
152
|
+
doc.font(isHeader ? FONT_BOLD : FONT_REGULAR).fontSize(fontSize);
|
|
153
|
+
let maxLines = 1;
|
|
154
|
+
columns.forEach((col) => {
|
|
155
|
+
const cellValue = isHeader ? col.header : row?.[col.key] !== void 0 ? String(row[col.key]) : "";
|
|
156
|
+
const textWidth = doc.widthOfString(cellValue);
|
|
157
|
+
const availableWidth = (col.width ?? minColumnWidth) - baseCellPadding * 2;
|
|
158
|
+
const lines = Math.ceil(textWidth / availableWidth);
|
|
159
|
+
if (lines > maxLines) {
|
|
160
|
+
maxLines = lines;
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
return Math.max(maxLines * (fontSize + 2) + baseCellPadding * 2, 25);
|
|
164
|
+
};
|
|
165
|
+
const headerHeight = calculateRowHeight(null, true);
|
|
166
|
+
const drawTableHeader = (y) => this.drawHeader(doc, columns, y, tableWidth, headerHeight, baseCellPadding, headerFontSize, minColumnWidth);
|
|
167
|
+
let currentY = drawTableHeader(startY);
|
|
168
|
+
doc.font(FONT_REGULAR).fontSize(dataFontSize);
|
|
169
|
+
rows.forEach((row, rowIndex) => {
|
|
170
|
+
const currentRowHeight = calculateRowHeight(row, false);
|
|
171
|
+
if (currentY + currentRowHeight > pageBottom) {
|
|
172
|
+
doc.addPage();
|
|
173
|
+
currentY = drawTableHeader(doc.page.margins.top);
|
|
174
|
+
doc.font(FONT_REGULAR).fontSize(dataFontSize);
|
|
175
|
+
}
|
|
176
|
+
currentY = this.drawRow(
|
|
177
|
+
doc,
|
|
178
|
+
row,
|
|
179
|
+
rowIndex,
|
|
180
|
+
columns,
|
|
181
|
+
currentY,
|
|
182
|
+
tableWidth,
|
|
183
|
+
currentRowHeight,
|
|
184
|
+
baseCellPadding,
|
|
185
|
+
dataFontSize,
|
|
186
|
+
minColumnWidth
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
drawHeader(doc, columns, startY, tableWidth, headerHeight, cellPadding, headerFontSize, minColumnWidth) {
|
|
191
|
+
doc.strokeColor(COLOR_BLACK).lineWidth(0.5).moveTo(doc.page.margins.left, startY).lineTo(doc.page.margins.left + tableWidth, startY).stroke();
|
|
192
|
+
doc.fillColor(COLOR_HEADER_BACKGROUND).rect(doc.page.margins.left, startY, tableWidth, headerHeight).fill();
|
|
193
|
+
let xPosition = doc.page.margins.left;
|
|
194
|
+
doc.fillColor(COLOR_BLACK).font(FONT_BOLD).fontSize(headerFontSize);
|
|
195
|
+
columns.forEach((col) => {
|
|
196
|
+
doc.strokeColor(COLOR_BLACK).lineWidth(0.5).moveTo(xPosition, startY).lineTo(xPosition, startY + headerHeight).stroke();
|
|
197
|
+
doc.text(col.header, xPosition + cellPadding, startY + cellPadding, {
|
|
198
|
+
width: (col.width ?? minColumnWidth) - cellPadding * 2,
|
|
199
|
+
align: "left",
|
|
200
|
+
lineBreak: false
|
|
201
|
+
});
|
|
202
|
+
xPosition += col.width ?? minColumnWidth;
|
|
203
|
+
});
|
|
204
|
+
doc.moveTo(xPosition, startY).lineTo(xPosition, startY + headerHeight).stroke();
|
|
205
|
+
doc.moveTo(doc.page.margins.left, startY + headerHeight).lineTo(xPosition, startY + headerHeight).stroke();
|
|
206
|
+
return startY + headerHeight;
|
|
207
|
+
}
|
|
208
|
+
drawRow(doc, row, rowIndex, columns, currentY, tableWidth, rowHeight, cellPadding, dataFontSize, minColumnWidth) {
|
|
209
|
+
doc.font(FONT_REGULAR).fontSize(dataFontSize);
|
|
210
|
+
if (rowIndex % 2 === 0) {
|
|
211
|
+
doc.fillColor(COLOR_ROW_BACKGROUND_ALTERNATE).rect(doc.page.margins.left, currentY, tableWidth, rowHeight).fill();
|
|
212
|
+
}
|
|
213
|
+
let xPosition = doc.page.margins.left;
|
|
214
|
+
doc.fillColor(COLOR_BLACK);
|
|
215
|
+
columns.forEach((col) => {
|
|
216
|
+
doc.strokeColor(COLOR_BLACK).lineWidth(0.5).moveTo(xPosition, currentY).lineTo(xPosition, currentY + rowHeight).stroke();
|
|
217
|
+
const rawValue = row[col.key];
|
|
218
|
+
const cellValue = typeof rawValue === "string" || typeof rawValue === "number" || typeof rawValue === "boolean" ? String(rawValue) : "";
|
|
219
|
+
const isNumeric = typeof rawValue === "number" || typeof rawValue === "string" && rawValue !== "" && !isNaN(Number(rawValue));
|
|
220
|
+
doc.text(cellValue, xPosition + cellPadding, currentY + cellPadding, {
|
|
221
|
+
width: (col.width ?? minColumnWidth) - cellPadding * 2,
|
|
222
|
+
align: isNumeric ? "right" : "left",
|
|
223
|
+
lineBreak: false
|
|
224
|
+
});
|
|
225
|
+
xPosition += col.width ?? minColumnWidth;
|
|
226
|
+
});
|
|
227
|
+
doc.moveTo(xPosition, currentY).lineTo(xPosition, currentY + rowHeight).stroke();
|
|
228
|
+
doc.moveTo(doc.page.margins.left, currentY + rowHeight).lineTo(xPosition, currentY + rowHeight).stroke();
|
|
229
|
+
return currentY + rowHeight;
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
233
|
+
0 && (module.exports = {
|
|
234
|
+
PdfExporter
|
|
235
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
interface PdfTableColumn {
|
|
2
|
+
header: string;
|
|
3
|
+
key: string;
|
|
4
|
+
width?: number;
|
|
5
|
+
}
|
|
6
|
+
interface PdfTable {
|
|
7
|
+
columns: PdfTableColumn[];
|
|
8
|
+
rows: Record<string, unknown>[];
|
|
9
|
+
}
|
|
10
|
+
interface PdfFilter {
|
|
11
|
+
label: string;
|
|
12
|
+
value: string;
|
|
13
|
+
}
|
|
14
|
+
interface PdfPage {
|
|
15
|
+
table?: PdfTable;
|
|
16
|
+
filters?: PdfFilter[];
|
|
17
|
+
}
|
|
18
|
+
declare class PdfExporter {
|
|
19
|
+
generate(content: {
|
|
20
|
+
pages: PdfPage[];
|
|
21
|
+
}): Promise<Buffer>;
|
|
22
|
+
private sanitizeSections;
|
|
23
|
+
drawFilters(doc: PDFKit.PDFDocument, filters: PdfFilter[]): number;
|
|
24
|
+
drawTable(doc: PDFKit.PDFDocument, table: PdfTable, startY?: number): void;
|
|
25
|
+
drawHeader(doc: PDFKit.PDFDocument, columns: PdfTableColumn[], startY: number, tableWidth: number, headerHeight: number, cellPadding: number, headerFontSize: number, minColumnWidth: number): number;
|
|
26
|
+
drawRow(doc: PDFKit.PDFDocument, row: Record<string, unknown>, rowIndex: number, columns: PdfTableColumn[], currentY: number, tableWidth: number, rowHeight: number, cellPadding: number, dataFontSize: number, minColumnWidth: number): number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export { PdfExporter, type PdfFilter, type PdfPage, type PdfTable, type PdfTableColumn };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
interface PdfTableColumn {
|
|
2
|
+
header: string;
|
|
3
|
+
key: string;
|
|
4
|
+
width?: number;
|
|
5
|
+
}
|
|
6
|
+
interface PdfTable {
|
|
7
|
+
columns: PdfTableColumn[];
|
|
8
|
+
rows: Record<string, unknown>[];
|
|
9
|
+
}
|
|
10
|
+
interface PdfFilter {
|
|
11
|
+
label: string;
|
|
12
|
+
value: string;
|
|
13
|
+
}
|
|
14
|
+
interface PdfPage {
|
|
15
|
+
table?: PdfTable;
|
|
16
|
+
filters?: PdfFilter[];
|
|
17
|
+
}
|
|
18
|
+
declare class PdfExporter {
|
|
19
|
+
generate(content: {
|
|
20
|
+
pages: PdfPage[];
|
|
21
|
+
}): Promise<Buffer>;
|
|
22
|
+
private sanitizeSections;
|
|
23
|
+
drawFilters(doc: PDFKit.PDFDocument, filters: PdfFilter[]): number;
|
|
24
|
+
drawTable(doc: PDFKit.PDFDocument, table: PdfTable, startY?: number): void;
|
|
25
|
+
drawHeader(doc: PDFKit.PDFDocument, columns: PdfTableColumn[], startY: number, tableWidth: number, headerHeight: number, cellPadding: number, headerFontSize: number, minColumnWidth: number): number;
|
|
26
|
+
drawRow(doc: PDFKit.PDFDocument, row: Record<string, unknown>, rowIndex: number, columns: PdfTableColumn[], currentY: number, tableWidth: number, rowHeight: number, cellPadding: number, dataFontSize: number, minColumnWidth: number): number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export { PdfExporter, type PdfFilter, type PdfPage, type PdfTable, type PdfTableColumn };
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// src/pdf/index.ts
|
|
2
|
+
import PDFDocument from "pdfkit";
|
|
3
|
+
|
|
4
|
+
// src/pdf/pdf.utils.ts
|
|
5
|
+
var stripEmojis = (text) => {
|
|
6
|
+
if (!text) {
|
|
7
|
+
return text;
|
|
8
|
+
}
|
|
9
|
+
return text.replace(
|
|
10
|
+
/[\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,
|
|
11
|
+
""
|
|
12
|
+
).replace(/\s+/g, " ").trim();
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// src/pdf/index.ts
|
|
16
|
+
var FONT_BOLD = "Helvetica-Bold";
|
|
17
|
+
var FONT_REGULAR = "Helvetica";
|
|
18
|
+
var COLOR_BLACK = "#000000";
|
|
19
|
+
var COLOR_HEADER_BACKGROUND = "#D3D3D3";
|
|
20
|
+
var COLOR_ROW_BACKGROUND_ALTERNATE = "#F5F5F5";
|
|
21
|
+
var PdfExporter = class {
|
|
22
|
+
generate(content) {
|
|
23
|
+
const doc = new PDFDocument({ margin: 30, size: "A4", layout: "landscape" });
|
|
24
|
+
const buffers = [];
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
doc.on("data", (chunk) => buffers.push(chunk));
|
|
27
|
+
doc.on("end", () => resolve(Buffer.concat(buffers)));
|
|
28
|
+
doc.on("error", reject);
|
|
29
|
+
try {
|
|
30
|
+
content.pages.forEach((section, index) => {
|
|
31
|
+
const sanitizedSections = this.sanitizeSections(section);
|
|
32
|
+
if (index > 0) {
|
|
33
|
+
doc.addPage();
|
|
34
|
+
}
|
|
35
|
+
let currentY = doc.y;
|
|
36
|
+
if (sanitizedSections.filters && sanitizedSections.filters.length > 0) {
|
|
37
|
+
currentY = this.drawFilters(doc, sanitizedSections.filters);
|
|
38
|
+
}
|
|
39
|
+
if (sanitizedSections.table) {
|
|
40
|
+
this.drawTable(doc, sanitizedSections.table, currentY);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
doc.end();
|
|
44
|
+
} catch (err) {
|
|
45
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
sanitizeSections(sections) {
|
|
50
|
+
const sanitized = {};
|
|
51
|
+
if (sections.filters) {
|
|
52
|
+
sanitized.filters = sections.filters.map((filter) => ({
|
|
53
|
+
label: stripEmojis(filter.label),
|
|
54
|
+
value: stripEmojis(filter.value)
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
if (sections.table) {
|
|
58
|
+
sanitized.table = {
|
|
59
|
+
columns: sections.table.columns.map((col) => ({
|
|
60
|
+
...col,
|
|
61
|
+
header: stripEmojis(col.header)
|
|
62
|
+
})),
|
|
63
|
+
rows: sections.table.rows.map((row) => {
|
|
64
|
+
const sanitizedRow = {};
|
|
65
|
+
for (const [key, value] of Object.entries(row)) {
|
|
66
|
+
sanitizedRow[key] = typeof value === "string" ? stripEmojis(value) : value;
|
|
67
|
+
}
|
|
68
|
+
return sanitizedRow;
|
|
69
|
+
})
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return sanitized;
|
|
73
|
+
}
|
|
74
|
+
drawFilters(doc, filters) {
|
|
75
|
+
doc.fillColor(COLOR_BLACK).font(FONT_BOLD).fontSize(11).text("Applied Filters", { align: "left" });
|
|
76
|
+
doc.moveDown(0.5);
|
|
77
|
+
filters.forEach((filter) => {
|
|
78
|
+
doc.font(FONT_BOLD).fontSize(11).text(filter.label, { continued: true });
|
|
79
|
+
doc.font(FONT_REGULAR).text(`: ${filter.value}`);
|
|
80
|
+
});
|
|
81
|
+
doc.moveDown(1);
|
|
82
|
+
return doc.y;
|
|
83
|
+
}
|
|
84
|
+
drawTable(doc, table, startY = doc.y) {
|
|
85
|
+
const columns = table.columns.length > 0 ? table.columns.map((c) => ({ ...c })) : [{ header: "No data", key: "no_data", width: 100 }];
|
|
86
|
+
const rows = table.rows ?? [];
|
|
87
|
+
const baseCellPadding = 4;
|
|
88
|
+
const minColumnWidth = 35;
|
|
89
|
+
const columnCount = columns.length;
|
|
90
|
+
const headerFontSize = columnCount > 13 ? 8 : columnCount > 10 ? 9 : columnCount > 7 ? 10 : 11;
|
|
91
|
+
const dataFontSize = columnCount > 13 ? 7 : columnCount > 10 ? 8 : columnCount > 7 ? 9 : 10;
|
|
92
|
+
columns.forEach((col) => {
|
|
93
|
+
doc.font(FONT_BOLD).fontSize(headerFontSize);
|
|
94
|
+
let maxWidth = doc.widthOfString(col.header);
|
|
95
|
+
doc.font(FONT_REGULAR).fontSize(dataFontSize);
|
|
96
|
+
rows.forEach((row) => {
|
|
97
|
+
const rv = row[col.key];
|
|
98
|
+
const cellValue = typeof rv === "string" || typeof rv === "number" || typeof rv === "boolean" ? String(rv) : "";
|
|
99
|
+
const textWidth = doc.widthOfString(cellValue);
|
|
100
|
+
if (textWidth > maxWidth) {
|
|
101
|
+
maxWidth = textWidth;
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
col.width = Math.max(maxWidth + baseCellPadding * 2, minColumnWidth);
|
|
105
|
+
});
|
|
106
|
+
const usableWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
|
107
|
+
const totalColumnWidth = columns.reduce((sum, c) => sum + (c.width ?? minColumnWidth), 0);
|
|
108
|
+
if (totalColumnWidth > usableWidth) {
|
|
109
|
+
const scale = usableWidth / totalColumnWidth;
|
|
110
|
+
columns.forEach((c) => {
|
|
111
|
+
c.width = Math.max(Math.floor((c.width ?? minColumnWidth) * scale), minColumnWidth);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
const tableWidth = columns.reduce((sum, c) => sum + (c.width ?? minColumnWidth), 0);
|
|
115
|
+
const pageBottom = doc.page.height - doc.page.margins.bottom;
|
|
116
|
+
const calculateRowHeight = (row, isHeader = false) => {
|
|
117
|
+
const fontSize = isHeader ? headerFontSize : dataFontSize;
|
|
118
|
+
doc.font(isHeader ? FONT_BOLD : FONT_REGULAR).fontSize(fontSize);
|
|
119
|
+
let maxLines = 1;
|
|
120
|
+
columns.forEach((col) => {
|
|
121
|
+
const cellValue = isHeader ? col.header : row?.[col.key] !== void 0 ? String(row[col.key]) : "";
|
|
122
|
+
const textWidth = doc.widthOfString(cellValue);
|
|
123
|
+
const availableWidth = (col.width ?? minColumnWidth) - baseCellPadding * 2;
|
|
124
|
+
const lines = Math.ceil(textWidth / availableWidth);
|
|
125
|
+
if (lines > maxLines) {
|
|
126
|
+
maxLines = lines;
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
return Math.max(maxLines * (fontSize + 2) + baseCellPadding * 2, 25);
|
|
130
|
+
};
|
|
131
|
+
const headerHeight = calculateRowHeight(null, true);
|
|
132
|
+
const drawTableHeader = (y) => this.drawHeader(doc, columns, y, tableWidth, headerHeight, baseCellPadding, headerFontSize, minColumnWidth);
|
|
133
|
+
let currentY = drawTableHeader(startY);
|
|
134
|
+
doc.font(FONT_REGULAR).fontSize(dataFontSize);
|
|
135
|
+
rows.forEach((row, rowIndex) => {
|
|
136
|
+
const currentRowHeight = calculateRowHeight(row, false);
|
|
137
|
+
if (currentY + currentRowHeight > pageBottom) {
|
|
138
|
+
doc.addPage();
|
|
139
|
+
currentY = drawTableHeader(doc.page.margins.top);
|
|
140
|
+
doc.font(FONT_REGULAR).fontSize(dataFontSize);
|
|
141
|
+
}
|
|
142
|
+
currentY = this.drawRow(
|
|
143
|
+
doc,
|
|
144
|
+
row,
|
|
145
|
+
rowIndex,
|
|
146
|
+
columns,
|
|
147
|
+
currentY,
|
|
148
|
+
tableWidth,
|
|
149
|
+
currentRowHeight,
|
|
150
|
+
baseCellPadding,
|
|
151
|
+
dataFontSize,
|
|
152
|
+
minColumnWidth
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
drawHeader(doc, columns, startY, tableWidth, headerHeight, cellPadding, headerFontSize, minColumnWidth) {
|
|
157
|
+
doc.strokeColor(COLOR_BLACK).lineWidth(0.5).moveTo(doc.page.margins.left, startY).lineTo(doc.page.margins.left + tableWidth, startY).stroke();
|
|
158
|
+
doc.fillColor(COLOR_HEADER_BACKGROUND).rect(doc.page.margins.left, startY, tableWidth, headerHeight).fill();
|
|
159
|
+
let xPosition = doc.page.margins.left;
|
|
160
|
+
doc.fillColor(COLOR_BLACK).font(FONT_BOLD).fontSize(headerFontSize);
|
|
161
|
+
columns.forEach((col) => {
|
|
162
|
+
doc.strokeColor(COLOR_BLACK).lineWidth(0.5).moveTo(xPosition, startY).lineTo(xPosition, startY + headerHeight).stroke();
|
|
163
|
+
doc.text(col.header, xPosition + cellPadding, startY + cellPadding, {
|
|
164
|
+
width: (col.width ?? minColumnWidth) - cellPadding * 2,
|
|
165
|
+
align: "left",
|
|
166
|
+
lineBreak: false
|
|
167
|
+
});
|
|
168
|
+
xPosition += col.width ?? minColumnWidth;
|
|
169
|
+
});
|
|
170
|
+
doc.moveTo(xPosition, startY).lineTo(xPosition, startY + headerHeight).stroke();
|
|
171
|
+
doc.moveTo(doc.page.margins.left, startY + headerHeight).lineTo(xPosition, startY + headerHeight).stroke();
|
|
172
|
+
return startY + headerHeight;
|
|
173
|
+
}
|
|
174
|
+
drawRow(doc, row, rowIndex, columns, currentY, tableWidth, rowHeight, cellPadding, dataFontSize, minColumnWidth) {
|
|
175
|
+
doc.font(FONT_REGULAR).fontSize(dataFontSize);
|
|
176
|
+
if (rowIndex % 2 === 0) {
|
|
177
|
+
doc.fillColor(COLOR_ROW_BACKGROUND_ALTERNATE).rect(doc.page.margins.left, currentY, tableWidth, rowHeight).fill();
|
|
178
|
+
}
|
|
179
|
+
let xPosition = doc.page.margins.left;
|
|
180
|
+
doc.fillColor(COLOR_BLACK);
|
|
181
|
+
columns.forEach((col) => {
|
|
182
|
+
doc.strokeColor(COLOR_BLACK).lineWidth(0.5).moveTo(xPosition, currentY).lineTo(xPosition, currentY + rowHeight).stroke();
|
|
183
|
+
const rawValue = row[col.key];
|
|
184
|
+
const cellValue = typeof rawValue === "string" || typeof rawValue === "number" || typeof rawValue === "boolean" ? String(rawValue) : "";
|
|
185
|
+
const isNumeric = typeof rawValue === "number" || typeof rawValue === "string" && rawValue !== "" && !isNaN(Number(rawValue));
|
|
186
|
+
doc.text(cellValue, xPosition + cellPadding, currentY + cellPadding, {
|
|
187
|
+
width: (col.width ?? minColumnWidth) - cellPadding * 2,
|
|
188
|
+
align: isNumeric ? "right" : "left",
|
|
189
|
+
lineBreak: false
|
|
190
|
+
});
|
|
191
|
+
xPosition += col.width ?? minColumnWidth;
|
|
192
|
+
});
|
|
193
|
+
doc.moveTo(xPosition, currentY).lineTo(xPosition, currentY + rowHeight).stroke();
|
|
194
|
+
doc.moveTo(doc.page.margins.left, currentY + rowHeight).lineTo(xPosition, currentY + rowHeight).stroke();
|
|
195
|
+
return currentY + rowHeight;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
export {
|
|
199
|
+
PdfExporter
|
|
200
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harbortouch/skytab-analytics-report-utils",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Centralized report column presentation configuration for SkyTab Analytics",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.6.0",
|
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
"types": "./dist/csv/index.d.ts",
|
|
20
20
|
"import": "./dist/csv/index.js",
|
|
21
21
|
"require": "./dist/csv/index.cjs"
|
|
22
|
+
},
|
|
23
|
+
"./pdf": {
|
|
24
|
+
"types": "./dist/pdf/index.d.ts",
|
|
25
|
+
"import": "./dist/pdf/index.js",
|
|
26
|
+
"require": "./dist/pdf/index.cjs"
|
|
22
27
|
}
|
|
23
28
|
},
|
|
24
29
|
"files": [
|
|
@@ -36,6 +41,8 @@
|
|
|
36
41
|
"prepare": "husky"
|
|
37
42
|
},
|
|
38
43
|
"devDependencies": {
|
|
44
|
+
"@types/node": "^26.0.1",
|
|
45
|
+
"@types/pdfkit": "^0.17.6",
|
|
39
46
|
"eslint": "^9.39.2",
|
|
40
47
|
"eslint-config-prettier": "^10.1.8",
|
|
41
48
|
"eslint-import-resolver-typescript": "^4.2.2",
|
|
@@ -52,6 +59,7 @@
|
|
|
52
59
|
},
|
|
53
60
|
"packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a",
|
|
54
61
|
"dependencies": {
|
|
55
|
-
"csv-stringify": "^6.8.
|
|
62
|
+
"csv-stringify": "^6.8.0",
|
|
63
|
+
"pdfkit": "^0.19.1"
|
|
56
64
|
}
|
|
57
65
|
}
|