@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.
@@ -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 → DATETIME(6) (user rule: always datetime, stored UTC)
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
- date: 'date', datetime: 'datetime', timestamp: 'datetime',
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
- // A calendar date, as YYYY-MM-DD, with NO timezone conversion.
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 toMysqlDatetime(v);
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 toMysqlDatetime(v);
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 → TIMESTAMPTZ (user rule: "always datetime, never date-only")
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
- // A calendar date, as YYYY-MM-DD, with NO timezone conversion.
102
- //
103
- // toISOString() would convert to UTC first, which is exactly the bug: a Date
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
- if (v instanceof Date) return v.toISOString();
255
- return v; // strings pass through; PG parses
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
@@ -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
+ };