@xeplr/actions 1.1.17 → 1.1.19

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.
@@ -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
+ };