@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.
- package/README.md +65 -7
- package/index.js +2 -0
- package/lib/builtins/db/move.js +8 -1
- package/lib/builtins/export/pages.js +92 -0
- package/lib/builtins/export/table.js +116 -0
- package/lib/builtins/file/upload.js +6 -1
- package/lib/builtins/index.js +8 -1
- package/lib/drivers/db/duckdb.js +96 -1
- package/lib/drivers/db/mssql.js +3 -2
- package/lib/drivers/db/mysql.js +2 -2
- package/lib/drivers/db/postgres.js +3 -2
- 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 +278 -0
- package/lib/uploader/index.js +13 -1
- package/package.json +23 -4
|
@@ -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
|
+
};
|