@xeplr/actions 1.1.18 → 1.1.20
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 +57 -2
- package/index.js +2 -0
- package/lib/builtins/export/pages.js +92 -0
- package/lib/builtins/export/table.js +116 -0
- package/lib/builtins/index.js +8 -1
- package/lib/drivers/db/dateValues.js +106 -0
- package/lib/drivers/db/duckdb.js +32 -20
- package/lib/drivers/db/mssql.js +21 -32
- package/lib/drivers/db/mysql.js +23 -32
- package/lib/drivers/db/postgres.js +32 -23
- package/lib/formats/index.js +3 -1
- package/lib/formats/writers/csv.js +78 -0
- package/lib/formats/writers/index.js +23 -0
- package/lib/formats/writers/pdf.js +293 -0
- package/lib/formats/writers/xlsx.js +284 -0
- package/lib/uploader/index.js +14 -0
- package/lib/uploader/reconcile.js +2 -1
- package/package.json +23 -4
package/lib/drivers/db/mysql.js
CHANGED
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
// number → DECIMAL(38,10) (exact; > 10 fractional digits are rounded —
|
|
20
20
|
// pre-create a wider column if you need more)
|
|
21
21
|
// boolean → TINYINT(1)
|
|
22
|
-
// date
|
|
23
|
-
// datetime→ DATETIME(6)
|
|
22
|
+
// date → DATE (the same day — dateValues.js)
|
|
23
|
+
// datetime → DATETIME(6) (an instant, written in UTC)
|
|
24
|
+
// localdatetime → DATETIME(6) (no zone; its digits as they were)
|
|
24
25
|
// object → JSON
|
|
25
26
|
// array → JSON
|
|
26
27
|
|
|
@@ -29,7 +30,8 @@ var TYPE_MAP = {
|
|
|
29
30
|
number: 'DECIMAL(38,10)',
|
|
30
31
|
boolean: 'TINYINT(1)',
|
|
31
32
|
date: 'DATE',
|
|
32
|
-
datetime: 'DATETIME(6)',
|
|
33
|
+
datetime: 'DATETIME(6)', // an instant, written in UTC
|
|
34
|
+
localdatetime: 'DATETIME(6)', // a wall-clock reading, written as its digits
|
|
33
35
|
object: 'JSON',
|
|
34
36
|
array: 'JSON'
|
|
35
37
|
};
|
|
@@ -43,30 +45,16 @@ var NATIVE_TO_LOGICAL = {
|
|
|
43
45
|
float: 'number', double: 'number', bit: 'boolean',
|
|
44
46
|
// 'date' maps to its OWN logical type — see reconcile.js. A DATE column
|
|
45
47
|
// created as DATETIME acquires a midnight that shifts under a timezone.
|
|
46
|
-
|
|
48
|
+
// DATETIME has no zone — its digits are copied as they are (dateValues.js).
|
|
49
|
+
// TIMESTAMP is an instant, read in UTC (the session is pinned to it below).
|
|
50
|
+
date: 'date', datetime: 'localdatetime', timestamp: 'datetime',
|
|
47
51
|
time: 'datetime', year: 'datetime',
|
|
48
52
|
json: 'object'
|
|
49
53
|
};
|
|
50
54
|
|
|
51
55
|
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
// toISOString() would convert to UTC first, which is exactly the bug: a Date
|
|
55
|
-
// holding 2026-01-05 local becomes 2026-01-04 in UTC anywhere east of
|
|
56
|
-
// Greenwich. The local getters are the right ones here precisely because a
|
|
57
|
-
// business date has no instant to convert.
|
|
58
|
-
function toDateOnly(v) {
|
|
59
|
-
if (v === null || v === undefined) return v;
|
|
60
|
-
if (v instanceof Date) {
|
|
61
|
-
if (isNaN(v.getTime())) return null;
|
|
62
|
-
return v.getFullYear() + '-' +
|
|
63
|
-
String(v.getMonth() + 1).padStart(2, '0') + '-' +
|
|
64
|
-
String(v.getDate()).padStart(2, '0');
|
|
65
|
-
}
|
|
66
|
-
// Already a date string — take the date part and leave the rest alone.
|
|
67
|
-
var m = /^(\d{4}-\d{2}-\d{2})/.exec(String(v));
|
|
68
|
-
return m ? m[1] : v;
|
|
69
|
-
}
|
|
56
|
+
// Dates and times: one shared rule for every driver (dateValues.js).
|
|
57
|
+
var dateValues = require('./dateValues');
|
|
70
58
|
|
|
71
59
|
var INTERNAL_ID_COL = '__xeplr_id__';
|
|
72
60
|
var INTERNAL_MOVEMENT_COL = '__xeplr_movement_id__';
|
|
@@ -96,6 +84,13 @@ async function connect(config) {
|
|
|
96
84
|
stringifyObjects: true
|
|
97
85
|
// multipleStatements stays at its default (off): one statement per query.
|
|
98
86
|
});
|
|
87
|
+
// THE SESSION IS IN UTC. MySQL hands a TIMESTAMP back in the session's zone
|
|
88
|
+
// (the server's own unless told), so without this the same instant read
|
|
89
|
+
// from a server set to India time came back 5½ hours off. Run first on
|
|
90
|
+
// every connection the pool opens; mysql2 queues it ahead of any query.
|
|
91
|
+
pool.pool.on('connection', function(conn) {
|
|
92
|
+
conn.query("SET time_zone = '+00:00'");
|
|
93
|
+
});
|
|
99
94
|
// READ-ONLY CONNECTIONS — `connect({ ..., readOnly: true })` for SQL the
|
|
100
95
|
// caller did not write itself. Every connection the pool opens is put into
|
|
101
96
|
// read-only transaction mode before it is used, so a write fails.
|
|
@@ -262,7 +257,7 @@ function coerceValue(v, type) {
|
|
|
262
257
|
|
|
263
258
|
switch (type) {
|
|
264
259
|
case 'string':
|
|
265
|
-
if (v instanceof Date) return
|
|
260
|
+
if (v instanceof Date) return dateValues.toInstantUtcClock(v);
|
|
266
261
|
if (Array.isArray(v)) return JSON.stringify(v);
|
|
267
262
|
if (typeof v === 'object') return JSON.stringify(v);
|
|
268
263
|
return String(v);
|
|
@@ -286,10 +281,13 @@ function coerceValue(v, type) {
|
|
|
286
281
|
return v; // let MySQL reject
|
|
287
282
|
|
|
288
283
|
case 'date':
|
|
289
|
-
return toDateOnly(v);
|
|
284
|
+
return dateValues.toDateOnly(v);
|
|
290
285
|
|
|
291
286
|
case 'datetime':
|
|
292
|
-
return
|
|
287
|
+
return dateValues.toInstantUtcClock(v);
|
|
288
|
+
|
|
289
|
+
case 'localdatetime':
|
|
290
|
+
return dateValues.toLocalDateTime(v);
|
|
293
291
|
|
|
294
292
|
case 'object':
|
|
295
293
|
case 'array':
|
|
@@ -301,13 +299,6 @@ function coerceValue(v, type) {
|
|
|
301
299
|
}
|
|
302
300
|
}
|
|
303
301
|
|
|
304
|
-
// 'YYYY-MM-DD HH:MM:SS.mmm' in UTC — the format MySQL DATETIME accepts.
|
|
305
|
-
// Unparseable input passes through so MySQL rejects it (→ error table).
|
|
306
|
-
function toMysqlDatetime(v) {
|
|
307
|
-
var d = (v instanceof Date) ? v : new Date(v);
|
|
308
|
-
if (isNaN(d.getTime())) return v;
|
|
309
|
-
return d.toISOString().slice(0, 23).replace('T', ' ');
|
|
310
|
-
}
|
|
311
302
|
|
|
312
303
|
async function getTableSchema(pool, tableName, schema) {
|
|
313
304
|
var r = await query(pool,
|
|
@@ -22,8 +22,9 @@
|
|
|
22
22
|
// string → TEXT
|
|
23
23
|
// number → NUMERIC (integer + float both handled)
|
|
24
24
|
// boolean → BOOLEAN
|
|
25
|
-
// date
|
|
26
|
-
// datetime→ TIMESTAMPTZ
|
|
25
|
+
// date → DATE (the same day — dateValues.js)
|
|
26
|
+
// datetime → TIMESTAMPTZ (an instant)
|
|
27
|
+
// localdatetime → TIMESTAMP (no zone; its digits as they were)
|
|
27
28
|
// object → JSONB
|
|
28
29
|
// array → JSONB
|
|
29
30
|
|
|
@@ -33,6 +34,7 @@ var TYPE_MAP = {
|
|
|
33
34
|
boolean: 'BOOLEAN',
|
|
34
35
|
date: 'DATE',
|
|
35
36
|
datetime: 'TIMESTAMPTZ',
|
|
37
|
+
localdatetime: 'TIMESTAMP',
|
|
36
38
|
object: 'JSONB',
|
|
37
39
|
array: 'JSONB'
|
|
38
40
|
};
|
|
@@ -60,7 +62,17 @@ async function connect(config) {
|
|
|
60
62
|
user: config.user,
|
|
61
63
|
password: config.password,
|
|
62
64
|
database: config.database,
|
|
63
|
-
max: config.maxConnections || 10
|
|
65
|
+
max: config.maxConnections || 10,
|
|
66
|
+
// THE ZONELESS TYPES COME BACK AS TEXT. node-pg turns a `date` and a
|
|
67
|
+
// `timestamp` into a JS Date read in THIS PROCESS's zone, so the same row
|
|
68
|
+
// read on a server in India and one in New York came back as two
|
|
69
|
+
// different instants — and a date a day apart. As text they are exactly
|
|
70
|
+
// the digits stored. `timestamptz` (1184) is a real instant and stays a
|
|
71
|
+
// Date.
|
|
72
|
+
types: { getTypeParser: function(oid, format) {
|
|
73
|
+
if (oid === 1082 || oid === 1114) return function(text) { return text; };
|
|
74
|
+
return pg.types.getTypeParser(oid, format);
|
|
75
|
+
} }
|
|
64
76
|
});
|
|
65
77
|
if (config.readOnly) pool.__xeplrReadOnly = true;
|
|
66
78
|
return pool;
|
|
@@ -98,24 +110,9 @@ async function query(pool, sql, params) {
|
|
|
98
110
|
|
|
99
111
|
function toPgType(type) { return TYPE_MAP[type] || 'TEXT'; }
|
|
100
112
|
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
// holding 2026-01-05 local becomes 2026-01-04 in UTC anywhere east of
|
|
105
|
-
// Greenwich. The local getters are the right ones here precisely because a
|
|
106
|
-
// business date has no instant to convert.
|
|
107
|
-
function toDateOnly(v) {
|
|
108
|
-
if (v === null || v === undefined) return v;
|
|
109
|
-
if (v instanceof Date) {
|
|
110
|
-
if (isNaN(v.getTime())) return null;
|
|
111
|
-
return v.getFullYear() + '-' +
|
|
112
|
-
String(v.getMonth() + 1).padStart(2, '0') + '-' +
|
|
113
|
-
String(v.getDate()).padStart(2, '0');
|
|
114
|
-
}
|
|
115
|
-
// Already a date string — take the date part and leave the rest alone.
|
|
116
|
-
var m = /^(\d{4}-\d{2}-\d{2})/.exec(String(v));
|
|
117
|
-
return m ? m[1] : v;
|
|
118
|
-
}
|
|
113
|
+
// Dates and times: one shared rule for every driver (dateValues.js).
|
|
114
|
+
var dateValues = require('./dateValues');
|
|
115
|
+
var toDateOnly = dateValues.toDateOnly;
|
|
119
116
|
|
|
120
117
|
|
|
121
118
|
// Double-quote a Postgres identifier, escaping embedded quotes.
|
|
@@ -251,8 +248,10 @@ function coerceValue(v, type) {
|
|
|
251
248
|
return toDateOnly(v);
|
|
252
249
|
|
|
253
250
|
case 'datetime':
|
|
254
|
-
|
|
255
|
-
|
|
251
|
+
return dateValues.toInstantIso(v);
|
|
252
|
+
|
|
253
|
+
case 'localdatetime':
|
|
254
|
+
return dateValues.toLocalDateTime(v);
|
|
256
255
|
|
|
257
256
|
case 'object':
|
|
258
257
|
case 'array':
|
|
@@ -395,7 +394,17 @@ async function rollbackMovement(pool, tableName, movementId) {
|
|
|
395
394
|
return result;
|
|
396
395
|
}
|
|
397
396
|
|
|
397
|
+
/**
|
|
398
|
+
* A zoned date-time column that holds zoneless values, retyped to zoneless with
|
|
399
|
+
* the same digits (read in UTC — how they were written). See the uploader's
|
|
400
|
+
* bootstrap for when this runs.
|
|
401
|
+
*/
|
|
402
|
+
function buildRetypeToLocalDateTimeSql(tableName, column) {
|
|
403
|
+
return 'ALTER TABLE ' + quoteIdent(tableName) + ' ALTER COLUMN ' + quoteIdent(column) + ' TYPE timestamp USING ' + quoteIdent(column) + " AT TIME ZONE 'UTC'";
|
|
404
|
+
}
|
|
405
|
+
|
|
398
406
|
module.exports = {
|
|
407
|
+
buildRetypeToLocalDateTimeSql: buildRetypeToLocalDateTimeSql,
|
|
399
408
|
requires: ['pg'],
|
|
400
409
|
|
|
401
410
|
// Connection lifecycle
|
package/lib/formats/index.js
CHANGED
|
@@ -21,8 +21,10 @@ function getFormat(type) {
|
|
|
21
21
|
function checkFormatRequires(actionName, format, mod) {
|
|
22
22
|
var requires = (mod && mod.requires) || [];
|
|
23
23
|
for (var i = 0; i < requires.length; i++) {
|
|
24
|
+
// A package of data files (a font) has no main to resolve; its
|
|
25
|
+
// package.json says it is installed just as well.
|
|
24
26
|
try { require.resolve(requires[i]); }
|
|
25
|
-
catch (_) {
|
|
27
|
+
catch (_) { try { require.resolve(requires[i] + '/package.json'); continue; } catch (__) { /* missing — fall through */ }
|
|
26
28
|
throw new Error(
|
|
27
29
|
actionName + ' requires "' + requires[i] + '" for format="' + format + '". ' +
|
|
28
30
|
'Install with: npm install ' + requires[i]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// CSV writer — the raw values, nothing formatted.
|
|
2
|
+
//
|
|
3
|
+
// RFC 4180: a field is quoted when it holds the delimiter, a quote, or a line
|
|
4
|
+
// break, and a quote inside is doubled. A UTF-8 byte order mark leads the
|
|
5
|
+
// file: without it Excel on Windows reads the file as the system code page
|
|
6
|
+
// and "₹" or "São Paulo" arrive as mojibake. Every other reader ignores it.
|
|
7
|
+
//
|
|
8
|
+
// A Date is written as ISO 8601 (YYYY-MM-DD when it is a whole UTC day), a
|
|
9
|
+
// boolean as true/false, null as an empty field.
|
|
10
|
+
//
|
|
11
|
+
// A field that would start with = + - @ (or a tab / carriage return) is
|
|
12
|
+
// prefixed with an apostrophe when `safe` is on (default): opened in a
|
|
13
|
+
// spreadsheet, such a cell is a FORMULA, and a value somebody typed into a
|
|
14
|
+
// source system would run on the reader's machine (CSV injection). Off for a
|
|
15
|
+
// file meant for another program rather than a person.
|
|
16
|
+
|
|
17
|
+
var fs = require('fs');
|
|
18
|
+
|
|
19
|
+
function isoOf(d) {
|
|
20
|
+
if (isNaN(d.getTime())) return '';
|
|
21
|
+
var iso = d.toISOString();
|
|
22
|
+
return iso.slice(10) === 'T00:00:00.000Z' ? iso.slice(0, 10) : iso;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = {
|
|
26
|
+
requires: [],
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {string} filePath
|
|
30
|
+
* @param {object} doc - { columns: [{ header }], delimiter?: ',', safe?: true }
|
|
31
|
+
* @returns {{ writeRow(row): Promise<boolean>, close(): Promise<{ rows, truncated }> }}
|
|
32
|
+
*/
|
|
33
|
+
open: function(filePath, doc) {
|
|
34
|
+
var delimiter = doc.delimiter || ',';
|
|
35
|
+
var safe = doc.safe !== false;
|
|
36
|
+
var out = fs.createWriteStream(filePath);
|
|
37
|
+
var failed = null;
|
|
38
|
+
out.on('error', function(err) { failed = err; });
|
|
39
|
+
var rows = 0;
|
|
40
|
+
var buffered = '';
|
|
41
|
+
|
|
42
|
+
function field(value) {
|
|
43
|
+
if (value === null || value === undefined) return '';
|
|
44
|
+
var text = value instanceof Date ? isoOf(value) : String(value);
|
|
45
|
+
if (safe && typeof value === 'string' && /^[=+\-@\t\r]/.test(text)) text = "'" + text;
|
|
46
|
+
if (text.indexOf(delimiter) !== -1 || /["\r\n]/.test(text)) text = '"' + text.replace(/"/g, '""') + '"';
|
|
47
|
+
return text;
|
|
48
|
+
}
|
|
49
|
+
function line(values) { return values.map(field).join(delimiter) + '\r\n'; }
|
|
50
|
+
|
|
51
|
+
async function push(text, final) {
|
|
52
|
+
buffered += text;
|
|
53
|
+
if (buffered.length < 1 << 20 && !final) return;
|
|
54
|
+
var chunk = buffered;
|
|
55
|
+
buffered = '';
|
|
56
|
+
if (failed) throw failed;
|
|
57
|
+
if (!out.write(chunk)) await new Promise(function(r) { out.once('drain', r); });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
var started = push('' + line((doc.columns || []).map(function(c) { return c.header == null ? '' : c.header; })));
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
writeRow: async function(row) {
|
|
64
|
+
await started;
|
|
65
|
+
rows += 1;
|
|
66
|
+
await push(line(row.values || []));
|
|
67
|
+
return true;
|
|
68
|
+
},
|
|
69
|
+
close: async function() {
|
|
70
|
+
await started;
|
|
71
|
+
await push('', true);
|
|
72
|
+
await new Promise(function(resolve, reject) { out.end(function(err) { return err ? reject(err) : resolve(); }); });
|
|
73
|
+
if (failed) throw failed;
|
|
74
|
+
return { rows: rows, truncated: false };
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// File WRITERS — the other direction from the parsers beside this folder.
|
|
2
|
+
// Each is { requires, open(filePath, doc) → { writeRow(row), close() } }.
|
|
3
|
+
// The PDF writer also lays out placed blocks (renderPages).
|
|
4
|
+
|
|
5
|
+
var WRITERS = {
|
|
6
|
+
csv: function() { return require('./csv'); },
|
|
7
|
+
xlsx: function() { return require('./xlsx'); },
|
|
8
|
+
pdf: function() { return require('./pdf'); }
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
var INFO = {
|
|
12
|
+
csv: { extension: 'csv', contentType: 'text/csv; charset=utf-8' },
|
|
13
|
+
xlsx: { extension: 'xlsx', contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
|
|
14
|
+
pdf: { extension: 'pdf', contentType: 'application/pdf' }
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function getWriter(type) {
|
|
18
|
+
var loader = WRITERS[type];
|
|
19
|
+
if (!loader) throw new Error('Unknown export format: "' + type + '". Supported: ' + Object.keys(WRITERS).join(', '));
|
|
20
|
+
return loader();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = { getWriter: getWriter, INFO: INFO, SUPPORTED: Object.keys(WRITERS) };
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// PDF writer, on pdfkit — a table as the reader saw it, or a page of placed
|
|
2
|
+
// blocks (a dashboard as it looked).
|
|
3
|
+
//
|
|
4
|
+
// Text is drawn from each cell's DISPLAY string: a PDF is for reading, so it
|
|
5
|
+
// shows exactly what the screen did ("₹1,234.50", "Jan 2024"), not the raw
|
|
6
|
+
// value. Numbers are right-aligned by the column's own `align`.
|
|
7
|
+
//
|
|
8
|
+
// FONT: pdfkit's built-in fonts are WinAnsi only — no "₹", no Devanagari, no
|
|
9
|
+
// CJK — and a missing glyph draws as nothing at all. DejaVu Sans Condensed
|
|
10
|
+
// (the `dejavu-fonts-ttf` peer) covers Latin, Greek, Cyrillic, the currency
|
|
11
|
+
// signs and box drawing, and is narrow enough for a table. Pass `font` /
|
|
12
|
+
// `boldFont` (paths to TTF/OTF) for scripts it lacks. Only the glyphs used
|
|
13
|
+
// are embedded, so the file stays small.
|
|
14
|
+
|
|
15
|
+
var fs = require('fs');
|
|
16
|
+
var path = require('path');
|
|
17
|
+
|
|
18
|
+
var MAX_TABLE_ROWS = 5000;
|
|
19
|
+
var COLORS = { text: '#1c2029', muted: '#6b7280', headerFill: '#f1f3f7', totalFill: '#e8ebf2', rule: '#9aa1ae', grid: '#e2e5eb' };
|
|
20
|
+
|
|
21
|
+
function fontPaths(opts) {
|
|
22
|
+
if (opts.font) return { body: opts.font, bold: opts.boldFont || opts.font };
|
|
23
|
+
var dir = path.join(path.dirname(require.resolve('dejavu-fonts-ttf/package.json')), 'ttf');
|
|
24
|
+
return { body: path.join(dir, 'DejaVuSansCondensed.ttf'), bold: path.join(dir, 'DejaVuSansCondensed-Bold.ttf') };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function newDocument(filePath, opts) {
|
|
28
|
+
var PDFDocument = require('pdfkit');
|
|
29
|
+
var doc = new PDFDocument({
|
|
30
|
+
size: opts.size || 'A4',
|
|
31
|
+
layout: opts.landscape ? 'landscape' : 'portrait',
|
|
32
|
+
margins: { top: 36, bottom: 36, left: 32, right: 32 },
|
|
33
|
+
bufferPages: true,
|
|
34
|
+
info: { Title: opts.title || 'Export', Producer: '@xeplr/actions' }
|
|
35
|
+
});
|
|
36
|
+
var fonts = fontPaths(opts);
|
|
37
|
+
doc.registerFont('body', fonts.body);
|
|
38
|
+
doc.registerFont('bold', fonts.bold);
|
|
39
|
+
doc.font('body');
|
|
40
|
+
var out = fs.createWriteStream(filePath);
|
|
41
|
+
var finished = new Promise(function(resolve, reject) { out.on('finish', resolve); out.on('error', reject); doc.on('error', reject); });
|
|
42
|
+
// Observed now, awaited later: a render that throws part-way never awaits
|
|
43
|
+
// it, and its rejection must not surface as an unhandled one.
|
|
44
|
+
finished.catch(function() {});
|
|
45
|
+
doc.pipe(out);
|
|
46
|
+
return {
|
|
47
|
+
doc: doc,
|
|
48
|
+
finished: finished,
|
|
49
|
+
/** A render that failed: close the stream and leave no half a file. */
|
|
50
|
+
abandon: function() {
|
|
51
|
+
try { doc.unpipe(out); } catch (e) { /* not piped */ }
|
|
52
|
+
out.destroy();
|
|
53
|
+
try { fs.rmSync(filePath, { force: true }); } catch (e) { /* already gone */ }
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** "Page 3 of 12" on every page, written once the page count is known. */
|
|
59
|
+
function stampPages(doc, footer) {
|
|
60
|
+
var range = doc.bufferedPageRange();
|
|
61
|
+
for (var i = range.start; i < range.start + range.count; i++) {
|
|
62
|
+
doc.switchToPage(i);
|
|
63
|
+
var bottom = doc.page.height - 24;
|
|
64
|
+
// A footer drawn below the bottom margin would otherwise open a new page.
|
|
65
|
+
var saved = doc.page.margins.bottom;
|
|
66
|
+
doc.page.margins.bottom = 0;
|
|
67
|
+
doc.font('body').fontSize(7.5).fillColor(COLORS.muted);
|
|
68
|
+
if (footer) doc.text(footer, doc.page.margins.left, bottom, { lineBreak: false });
|
|
69
|
+
doc.text('Page ' + (i - range.start + 1) + ' of ' + range.count, doc.page.margins.left, bottom,
|
|
70
|
+
{ width: doc.page.width - doc.page.margins.left - doc.page.margins.right, align: 'right', lineBreak: false });
|
|
71
|
+
doc.page.margins.bottom = saved;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Column widths for a table drawn `width` points wide: each column as wide as
|
|
77
|
+
* its header and its widest sampled value, then scaled to fill the width —
|
|
78
|
+
* shrinking the font first when the natural widths do not fit, so a wide
|
|
79
|
+
* table still reads rather than truncating every cell.
|
|
80
|
+
*/
|
|
81
|
+
function layoutColumns(doc, columns, sampleRows, width, baseSize) {
|
|
82
|
+
var size = baseSize;
|
|
83
|
+
var natural;
|
|
84
|
+
for (;;) {
|
|
85
|
+
natural = columns.map(function(c, i) {
|
|
86
|
+
doc.font('bold').fontSize(size);
|
|
87
|
+
var w = doc.widthOfString(String(c.header == null ? '' : c.header));
|
|
88
|
+
doc.font('body').fontSize(size);
|
|
89
|
+
sampleRows.forEach(function(r) {
|
|
90
|
+
var t = cellText(r, i);
|
|
91
|
+
if (t) w = Math.max(w, doc.widthOfString(t));
|
|
92
|
+
});
|
|
93
|
+
return Math.min(w, width * 0.45) + size * 1.2;
|
|
94
|
+
});
|
|
95
|
+
var total = natural.reduce(function(a, b) { return a + b; }, 0);
|
|
96
|
+
if (total <= width || size <= 6) break;
|
|
97
|
+
size = Math.max(6, size - 0.5);
|
|
98
|
+
}
|
|
99
|
+
var sum = natural.reduce(function(a, b) { return a + b; }, 0) || 1;
|
|
100
|
+
return { size: size, widths: natural.map(function(w) { return w * width / sum; }) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function cellText(row, i) {
|
|
104
|
+
var v = row.display ? row.display[i] : (row.values || [])[i];
|
|
105
|
+
return v === null || v === undefined ? '' : String(v);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Draws a header and rows into a box. Paged: when `paged`, a row that would
|
|
110
|
+
* cross the bottom opens a new page and repeats the header; otherwise rows
|
|
111
|
+
* stop at the box and the rest are counted.
|
|
112
|
+
* @returns {{ drawn, hidden }}
|
|
113
|
+
*/
|
|
114
|
+
function drawTable(doc, box, columns, rows, opts) {
|
|
115
|
+
var layout = layoutColumns(doc, columns, rows.slice(0, 300), box.w, opts.fontSize || 8.5);
|
|
116
|
+
var size = layout.size;
|
|
117
|
+
var rowH = size * 1.95;
|
|
118
|
+
var pad = size * 0.6;
|
|
119
|
+
var y = box.y;
|
|
120
|
+
var bottom = box.y + box.h;
|
|
121
|
+
|
|
122
|
+
function drawRow(cells, look) {
|
|
123
|
+
var x = box.x;
|
|
124
|
+
if (look.fill) doc.rect(box.x, y, box.w, rowH).fill(look.fill);
|
|
125
|
+
if (look.rule) doc.moveTo(box.x, y).lineTo(box.x + box.w, y).lineWidth(0.8).strokeColor(COLORS.rule).stroke();
|
|
126
|
+
doc.font(look.bold ? 'bold' : 'body').fontSize(size).fillColor(COLORS.text);
|
|
127
|
+
for (var i = 0; i < columns.length; i++) {
|
|
128
|
+
var w = layout.widths[i];
|
|
129
|
+
var align = columns[i].align === 'right' || columns[i].align === 'center' ? columns[i].align : 'left';
|
|
130
|
+
doc.text(cells[i], x + pad / 2, y + (rowH - size) / 2 - 1, { width: Math.max(1, w - pad), align: align, lineBreak: false, ellipsis: true, height: rowH });
|
|
131
|
+
x += w;
|
|
132
|
+
}
|
|
133
|
+
if (!look.fill && !look.rule) doc.moveTo(box.x, y + rowH).lineTo(box.x + box.w, y + rowH).lineWidth(0.4).strokeColor(COLORS.grid).stroke();
|
|
134
|
+
y += rowH;
|
|
135
|
+
}
|
|
136
|
+
function header() {
|
|
137
|
+
drawRow(columns.map(function(c) { return c.header == null ? '' : String(c.header); }), { bold: true, fill: COLORS.headerFill });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
header();
|
|
141
|
+
var drawn = 0;
|
|
142
|
+
for (var r = 0; r < rows.length; r++) {
|
|
143
|
+
if (y + rowH > bottom) {
|
|
144
|
+
if (!opts.paged) break;
|
|
145
|
+
doc.addPage();
|
|
146
|
+
y = doc.page.margins.top;
|
|
147
|
+
bottom = doc.page.height - doc.page.margins.bottom - 14;
|
|
148
|
+
header();
|
|
149
|
+
}
|
|
150
|
+
var kind = rows[r].kind;
|
|
151
|
+
drawRow(columns.map(function(c, i) { return cellText(rows[r], i); }),
|
|
152
|
+
kind === 'total' ? { bold: true, fill: COLORS.totalFill, rule: true } : kind === 'subtotal' ? { bold: true } : {});
|
|
153
|
+
drawn += 1;
|
|
154
|
+
}
|
|
155
|
+
return { drawn: drawn, hidden: rows.length - drawn, endY: y };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = {
|
|
159
|
+
requires: ['pdfkit', 'dejavu-fonts-ttf'],
|
|
160
|
+
MAX_TABLE_ROWS: MAX_TABLE_ROWS,
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* A table, paged. Rows are held until close (pdfkit lays out page by page,
|
|
164
|
+
* and the column widths need a look at the rows), so the row cap matters:
|
|
165
|
+
* a PDF is for reading, and 5,000 rows is already a hundred pages.
|
|
166
|
+
*
|
|
167
|
+
* @param {object} doc - { title?, subtitle?, footer?, columns: [{ header, align? }], maxRows?, font?, boldFont? }
|
|
168
|
+
*/
|
|
169
|
+
open: function(filePath, doc) {
|
|
170
|
+
var columns = doc.columns || [];
|
|
171
|
+
var cap = doc.maxRows || MAX_TABLE_ROWS;
|
|
172
|
+
var rows = [];
|
|
173
|
+
var offered = 0;
|
|
174
|
+
return {
|
|
175
|
+
writeRow: async function(row) {
|
|
176
|
+
offered += 1;
|
|
177
|
+
if (rows.length >= cap) return false;
|
|
178
|
+
rows.push(row);
|
|
179
|
+
return true;
|
|
180
|
+
},
|
|
181
|
+
close: async function() {
|
|
182
|
+
var made = newDocument(filePath, { title: doc.title, landscape: columns.length > 6, font: doc.font, boldFont: doc.boldFont });
|
|
183
|
+
var pdf = made.doc;
|
|
184
|
+
try {
|
|
185
|
+
var left = pdf.page.margins.left;
|
|
186
|
+
var width = pdf.page.width - left - pdf.page.margins.right;
|
|
187
|
+
var y = pdf.page.margins.top;
|
|
188
|
+
if (doc.title) { pdf.font('bold').fontSize(14).fillColor(COLORS.text).text(doc.title, left, y, { width: width }); y = pdf.y + 2; }
|
|
189
|
+
if (doc.subtitle) { pdf.font('body').fontSize(8.5).fillColor(COLORS.muted).text(doc.subtitle, left, y, { width: width }); y = pdf.y; }
|
|
190
|
+
if (doc.title || doc.subtitle) y += 8;
|
|
191
|
+
var result = drawTable(pdf, { x: left, y: y, w: width, h: pdf.page.height - pdf.page.margins.bottom - 14 - y }, columns, rows, { paged: true });
|
|
192
|
+
if (offered > rows.length) {
|
|
193
|
+
var noteY = result.endY + 6;
|
|
194
|
+
if (noteY + 12 > pdf.page.height - pdf.page.margins.bottom - 14) { pdf.addPage(); noteY = pdf.page.margins.top; }
|
|
195
|
+
pdf.font('body').fontSize(8).fillColor(COLORS.muted).text('Showing the first ' + rows.length.toLocaleString('en-US') + ' of ' +
|
|
196
|
+
offered.toLocaleString('en-US') + ' rows. Export to Excel or CSV for all of them.', left, noteY, { width: width });
|
|
197
|
+
}
|
|
198
|
+
stampPages(pdf, doc.footer);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
made.abandon();
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
203
|
+
pdf.end();
|
|
204
|
+
await made.finished;
|
|
205
|
+
return { rows: rows.length, truncated: offered > rows.length };
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Pages of placed blocks — a dashboard as it looked. Every position is a
|
|
212
|
+
* FRACTION of the page's content box, so the layout keeps its proportions
|
|
213
|
+
* on any paper size.
|
|
214
|
+
*
|
|
215
|
+
* blocks:
|
|
216
|
+
* { type: 'text', x, y, w, h, text, size? | sizeRel?, bold?, color?, align? } — sizeRel: a fraction of the page width
|
|
217
|
+
* { type: 'table', x, y, w, h, title?, columns, rows } — no paging; rows past the box are counted
|
|
218
|
+
* { type: 'image', x, y, w, h, src } — a data: URL, PNG or JPEG only
|
|
219
|
+
* { type: 'rect', x, y, w, h, fill?, stroke?, strokeWidth?, radius? }
|
|
220
|
+
* { type: 'line', x, y, w, h, stroke?, strokeWidth? } — from (x,y) to (x+w,y+h)
|
|
221
|
+
*
|
|
222
|
+
* @param {object} spec - { pages: [{ blocks }], title?, footer?, landscape?: true, font?, boldFont? }
|
|
223
|
+
*/
|
|
224
|
+
renderPages: async function(filePath, spec) {
|
|
225
|
+
var made = newDocument(filePath, { title: spec.title, landscape: spec.landscape !== false, font: spec.font, boldFont: spec.boldFont });
|
|
226
|
+
var pdf = made.doc;
|
|
227
|
+
var pages = spec.pages && spec.pages.length ? spec.pages : [{ blocks: [] }];
|
|
228
|
+
try {
|
|
229
|
+
var counts = { blocks: 0, hiddenRows: 0, badImages: 0 };
|
|
230
|
+
pages.forEach(function(page, p) {
|
|
231
|
+
if (p > 0) pdf.addPage();
|
|
232
|
+
var m = pdf.page.margins;
|
|
233
|
+
var box = { x: m.left, y: m.top, w: pdf.page.width - m.left - m.right, h: pdf.page.height - m.top - m.bottom - 14 };
|
|
234
|
+
if (p === 0 && spec.title) {
|
|
235
|
+
pdf.font('bold').fontSize(13).fillColor(COLORS.text).text(spec.title, box.x, box.y, { width: box.w, lineBreak: false });
|
|
236
|
+
var used = pdf.y - box.y + 6;
|
|
237
|
+
box = { x: box.x, y: box.y + used, w: box.w, h: box.h - used };
|
|
238
|
+
}
|
|
239
|
+
(page.blocks || []).forEach(function(b) {
|
|
240
|
+
counts.blocks += 1;
|
|
241
|
+
var at = { x: box.x + b.x * box.w, y: box.y + b.y * box.h, w: b.w * box.w, h: b.h * box.h };
|
|
242
|
+
pdf.save();
|
|
243
|
+
pdf.rect(at.x, at.y, at.w, at.h).clip();
|
|
244
|
+
if (b.type === 'rect') {
|
|
245
|
+
var shape = b.radius ? pdf.roundedRect(at.x, at.y, at.w, at.h, b.radius) : pdf.rect(at.x, at.y, at.w, at.h);
|
|
246
|
+
if (b.fill && b.stroke) shape.lineWidth(b.strokeWidth || 1).fillAndStroke(b.fill, b.stroke);
|
|
247
|
+
else if (b.fill) shape.fill(b.fill);
|
|
248
|
+
else if (b.stroke) shape.lineWidth(b.strokeWidth || 1).stroke(b.stroke);
|
|
249
|
+
} else if (b.type === 'line') {
|
|
250
|
+
pdf.restore(); pdf.save(); // a line is not clipped to its own (often zero-height) box
|
|
251
|
+
pdf.moveTo(at.x, at.y).lineTo(at.x + at.w, at.y + at.h).lineWidth(b.strokeWidth || 1).strokeColor(b.stroke || COLORS.rule).stroke();
|
|
252
|
+
} else if (b.type === 'image') {
|
|
253
|
+
var match = /^data:image\/(png|jpe?g);base64,([A-Za-z0-9+/=]+)$/.exec(String(b.src || ''));
|
|
254
|
+
// One chart that would not capture must not sink the whole board:
|
|
255
|
+
// its box says so and the rest is drawn.
|
|
256
|
+
try {
|
|
257
|
+
if (match) pdf.image(Buffer.from(match[2], 'base64'), at.x, at.y, { fit: [at.w, at.h], align: 'center', valign: 'center' });
|
|
258
|
+
} catch (err) {
|
|
259
|
+
counts.badImages += 1;
|
|
260
|
+
pdf.font('body').fontSize(8).fillColor(COLORS.muted).text('This chart could not be drawn', at.x, at.y + at.h / 2 - 4, { width: at.w, align: 'center', lineBreak: false });
|
|
261
|
+
}
|
|
262
|
+
} else if (b.type === 'table') {
|
|
263
|
+
var top = at.y;
|
|
264
|
+
if (b.title) {
|
|
265
|
+
pdf.font('bold').fontSize(9).fillColor(COLORS.text).text(b.title, at.x, at.y, { width: at.w, lineBreak: false, ellipsis: true });
|
|
266
|
+
top = at.y + 14;
|
|
267
|
+
}
|
|
268
|
+
var drawn = drawTable(pdf, { x: at.x, y: top, w: at.w, h: at.y + at.h - top }, b.columns || [], b.rows || [], { paged: false, fontSize: 7.5 });
|
|
269
|
+
counts.hiddenRows += drawn.hidden;
|
|
270
|
+
if (drawn.hidden > 0) {
|
|
271
|
+
pdf.font('body').fontSize(7).fillColor(COLORS.muted)
|
|
272
|
+
.text('+' + drawn.hidden.toLocaleString('en-US') + ' more rows', at.x, at.y + at.h - 9, { width: at.w, align: 'right', lineBreak: false });
|
|
273
|
+
}
|
|
274
|
+
} else if (b.type === 'text') {
|
|
275
|
+
// sizeRel: a fraction of the page width — how a screen's text keeps
|
|
276
|
+
// its proportion on paper of another width.
|
|
277
|
+
var size = b.sizeRel ? Math.max(5, Math.min(72, b.sizeRel * box.w)) : (b.size || 10);
|
|
278
|
+
pdf.font(b.bold ? 'bold' : 'body').fontSize(size).fillColor(b.color || COLORS.text)
|
|
279
|
+
.text(String(b.text == null ? '' : b.text), at.x, at.y, { width: at.w, height: at.h, align: b.align || 'left', ellipsis: true });
|
|
280
|
+
}
|
|
281
|
+
pdf.restore();
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
stampPages(pdf, spec.footer);
|
|
285
|
+
} catch (err) {
|
|
286
|
+
made.abandon();
|
|
287
|
+
throw err;
|
|
288
|
+
}
|
|
289
|
+
pdf.end();
|
|
290
|
+
await made.finished;
|
|
291
|
+
return { pages: pages.length, blocks: counts.blocks, hiddenRows: counts.hiddenRows, badImages: counts.badImages };
|
|
292
|
+
}
|
|
293
|
+
};
|