@xeplr/actions 1.1.18 → 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 +44 -0
- 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/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/package.json +23 -4
package/README.md
CHANGED
|
@@ -107,6 +107,8 @@ Register the ones you want (`register(actions.builtins.dbFetch)`) or pass a modu
|
|
|
107
107
|
| key | action name | does |
|
|
108
108
|
|---|---|---|
|
|
109
109
|
| `dataOperation` | `data-operation` | From a list of rows, make the list the next step needs — keep some rows, pick and rename columns, compute new ones, sort, optionally render. See [Data operation](#data-operation). |
|
|
110
|
+
| `exportTable` | `export-table` | Rows to a **CSV, Excel (.xlsx) or PDF** file. See [Export](#export). |
|
|
111
|
+
| `exportPages` | `export-pages` | Pages of placed blocks (text, tables, images, shapes) to a PDF — a dashboard as it looked. See [Export](#export). |
|
|
110
112
|
| `dbFetch` | `db-fetch` | Stream rows from a table (`mode: 'table'`) or SQL (`mode: 'query'`, `params`). Default `streaming_mode: true` → NDJSON file `{ filePath, format: 'jsonl', bytes, rows }`; `false` → `{ rows, rowCount }`. |
|
|
111
113
|
| `dbPush` | `db-push` | Write `rows` or an NDJSON `filePath` into `targetTable` through the uploader. `primaryKeys` → upsert. |
|
|
112
114
|
| `dbMove` | `db-move` | One window of rows from a source table, query or procedure into a target table in one streamed pass. See [db-move](#db-move). |
|
|
@@ -220,6 +222,48 @@ should not take the other eight hundred and ninety-nine with it.
|
|
|
220
222
|
**No aggregation.** `group by status, count` changes what a row *is*; that is
|
|
221
223
|
a different shape and, when it is wanted, a different step.
|
|
222
224
|
|
|
225
|
+
## Export
|
|
226
|
+
|
|
227
|
+
`export-table` writes rows that arrive **already presented**: which columns, in what order, under what heading, and for each cell its raw value and the text a reader saw. The action knows nothing of reports or pivots; the caller decides all that (in xeplr-bi, `report-engine`'s `presentTable` does). Each format takes what it needs:
|
|
228
|
+
|
|
229
|
+
| format | writes | from |
|
|
230
|
+
|---|---|---|
|
|
231
|
+
| `csv` | RFC 4180; a UTF-8 byte-order mark so Excel reads `₹` and accents; a text cell starting with `= + - @` gets a leading `'` (no formula injection; numbers untouched) | `values` |
|
|
232
|
+
| `xlsx` | numbers and dates as **real typed cells carrying the column's Excel number format** (`"₹"#,##0.00`, `0.0%`, `mmm yyyy`, `#,##0.0,,"M"`), so the sheet looks like the screen and still sums; an optional title and subtitle; a bold, frozen header with an autofilter; bold subtotals and a tinted total row; column widths from the first 200 rows. Excel's 1,048,576-row limit is respected (`truncated`) | `values` |
|
|
233
|
+
| `pdf` | each cell exactly as displayed, numbers right-aligned by `align`, header repeated on every page, landscape past 6 columns, "Page x of y", bold or tinted total rows. Capped at 5,000 rows (`maxRows`) with a closing note | `display` |
|
|
234
|
+
|
|
235
|
+
```js
|
|
236
|
+
await runAction(builtins.exportTable, {
|
|
237
|
+
format: 'xlsx',
|
|
238
|
+
document: {
|
|
239
|
+
title: 'Sales by region', subtitle: 'Date: Current financial year',
|
|
240
|
+
columns: [
|
|
241
|
+
{ header: 'Region' },
|
|
242
|
+
{ header: 'Month', kind: 'date', numFmt: 'mmm yyyy' },
|
|
243
|
+
{ header: 'Revenue', kind: 'number', numFmt: '"₹"#,##0.00', align: 'right' }
|
|
244
|
+
]
|
|
245
|
+
},
|
|
246
|
+
rows: [{ values: ['North', '2024-01-01', 1234.5], display: ['North', 'Jan 2024', '₹1,234.50'], kind: 'detail' }]
|
|
247
|
+
// or rowsFile: an NDJSON file, one row per line — streamed, never held
|
|
248
|
+
})
|
|
249
|
+
// → { filePath, fileName, contentType, bytes, rows, truncated }
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
- **Streaming.** CSV and Excel stream rows to disk, from `rows` or from `rowsFile` (NDJSON, one row per line, as `streaming.spool` writes it); 200,000 rows to xlsx take about 2 s in flat memory. Our own xlsx writer runs on `fflate`. `exceljs` was rejected: it pulls a `uuid` with a published advisory and has had no release since December 2024.
|
|
253
|
+
- **Fonts.** pdfkit's built-in fonts cannot draw `₹` or non-Latin text; a missing glyph draws as nothing. The PDF embeds **DejaVu Sans Condensed** (the `dejavu-fonts-ttf` peer). Pass `document.font` / `boldFont` (TTF/OTF paths) for scripts it lacks. Only the glyphs used are embedded.
|
|
254
|
+
- **Output** goes to `outputPath`, or to a new directory under `XEPLR_ACTIONS_TMP_DIR` with a file named after the title. A failed export leaves no half-written file.
|
|
255
|
+
|
|
256
|
+
`export-pages` lays out `pages: [{ blocks }]`, each block positioned by **fractions** of the page's content box (A4 landscape by default):
|
|
257
|
+
- `text` (`text`, `size`, `bold`, `color`, `align`)
|
|
258
|
+
- `table` (`title`, `columns`, `rows`; drawn inside its box, and rows that don't fit are counted as "+N more rows")
|
|
259
|
+
- `image` (`src` must be a **PNG or JPEG data URL**: no fetching, no SVG, no markup)
|
|
260
|
+
- `rect` (`fill`, `stroke`, `radius`)
|
|
261
|
+
- `line`
|
|
262
|
+
|
|
263
|
+
What it is given is assumed to come from a browser, so it is bounded: 50 pages, 200 blocks a page, 8 MB per image, 500 table rows, and 5,000 characters of text. An image that will not decode draws a note in its box instead of failing the whole file.
|
|
264
|
+
|
|
265
|
+
Peers (optional, needed only by the format used): `fflate` for xlsx; `pdfkit` and `dejavu-fonts-ttf` for pdf. `writers` (`getWriter`, `INFO`, `SUPPORTED`) is exported for code that wants a writer without the action.
|
|
266
|
+
|
|
223
267
|
## Uploader
|
|
224
268
|
|
|
225
269
|
`uploader.upload(opts)` is what `db-push`, `db-move` and `file-upload` use:
|
package/index.js
CHANGED
|
@@ -41,6 +41,8 @@ module.exports = {
|
|
|
41
41
|
streaming: streaming,
|
|
42
42
|
drivers: { db: dbDrivers, file: fileDrivers },
|
|
43
43
|
formats: formats,
|
|
44
|
+
// { getWriter(type), INFO, SUPPORTED } — csv / xlsx / pdf writers behind export-table.
|
|
45
|
+
writers: require('./lib/formats/writers'),
|
|
44
46
|
|
|
45
47
|
// xeplr_configs — shared control-plane DB (import/movement metadata).
|
|
46
48
|
// One call per app: attachConfig({ service }); await xcfg.ready(). See
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// export-pages — placed blocks to a PDF: a dashboard as it looked.
|
|
2
|
+
//
|
|
3
|
+
// Each page is a list of blocks positioned by FRACTIONS of the page's content
|
|
4
|
+
// box (x, w across; y, h down), so a layout drawn on any screen keeps its
|
|
5
|
+
// proportions on paper. A block is text, a small table, an image (a PNG or
|
|
6
|
+
// JPEG data URL — a chart is captured as one), a rectangle or a line. See
|
|
7
|
+
// lib/formats/writers/pdf.js renderPages for each block's fields.
|
|
8
|
+
//
|
|
9
|
+
// Bounded on purpose: what arrives here was posted by a browser, so the page,
|
|
10
|
+
// block and image counts and sizes are capped and an image must be a data URL
|
|
11
|
+
// of a raster type — no fetching, no markup.
|
|
12
|
+
|
|
13
|
+
var fs = require('fs');
|
|
14
|
+
var os = require('os');
|
|
15
|
+
var path = require('path');
|
|
16
|
+
var pdf = require('../../formats/writers/pdf');
|
|
17
|
+
var { checkFormatRequires } = require('../../formats');
|
|
18
|
+
|
|
19
|
+
var LIMITS = { pages: 50, blocksPerPage: 200, imageBytes: 8 * 1024 * 1024, tableRows: 500, text: 5000 };
|
|
20
|
+
var TYPES = ['text', 'table', 'image', 'rect', 'line'];
|
|
21
|
+
|
|
22
|
+
function fail(message) {
|
|
23
|
+
var err = new Error(message);
|
|
24
|
+
err.status = 400;
|
|
25
|
+
return err;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function fraction(n) { return typeof n === 'number' && isFinite(n) && n >= -0.01 && n <= 50; }
|
|
29
|
+
|
|
30
|
+
/** Throws (400) naming the first block that is not one this action draws. */
|
|
31
|
+
function validate(spec) {
|
|
32
|
+
var pages = spec.pages;
|
|
33
|
+
if (!Array.isArray(pages) || !pages.length) throw fail('pages must list at least one page');
|
|
34
|
+
if (pages.length > LIMITS.pages) throw fail('At most ' + LIMITS.pages + ' pages');
|
|
35
|
+
pages.forEach(function(page, p) {
|
|
36
|
+
var blocks = (page && page.blocks) || [];
|
|
37
|
+
if (!Array.isArray(blocks)) throw fail('pages[' + p + '].blocks must be a list');
|
|
38
|
+
if (blocks.length > LIMITS.blocksPerPage) throw fail('At most ' + LIMITS.blocksPerPage + ' blocks on a page');
|
|
39
|
+
blocks.forEach(function(b, i) {
|
|
40
|
+
var where = 'pages[' + p + '].blocks[' + i + ']';
|
|
41
|
+
if (!b || TYPES.indexOf(b.type) === -1) throw fail(where + ': type must be one of ' + TYPES.join(', '));
|
|
42
|
+
if (![b.x, b.y, b.w, b.h].every(fraction)) throw fail(where + ': x, y, w and h are fractions of the page');
|
|
43
|
+
if (b.type === 'image') {
|
|
44
|
+
var src = String(b.src || '');
|
|
45
|
+
if (!/^data:image\/(png|jpe?g);base64,[A-Za-z0-9+/=]+$/.test(src)) throw fail(where + ': src must be a PNG or JPEG data URL');
|
|
46
|
+
if (src.length * 0.75 > LIMITS.imageBytes) throw fail(where + ': image is larger than ' + (LIMITS.imageBytes >> 20) + ' MB');
|
|
47
|
+
}
|
|
48
|
+
if (b.type === 'table') {
|
|
49
|
+
if (!Array.isArray(b.columns) || !Array.isArray(b.rows)) throw fail(where + ': a table needs columns and rows');
|
|
50
|
+
if (b.rows.length > LIMITS.tableRows) b.rows = b.rows.slice(0, LIMITS.tableRows);
|
|
51
|
+
}
|
|
52
|
+
if (b.type === 'text' && String(b.text == null ? '' : b.text).length > LIMITS.text) b.text = String(b.text).slice(0, LIMITS.text);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = {
|
|
58
|
+
name: 'export-pages',
|
|
59
|
+
description: 'Lay out pages of placed blocks (text, tables, images, shapes) as a PDF — a dashboard as it looked.',
|
|
60
|
+
requires: [],
|
|
61
|
+
LIMITS: LIMITS,
|
|
62
|
+
|
|
63
|
+
inputSchema: [
|
|
64
|
+
{ name: 'pages', type: 'array', required: true, order: 1,
|
|
65
|
+
description: '[{ blocks: [{ type: text|table|image|rect|line, x, y, w, h, … }] }], positions as fractions of the page.' },
|
|
66
|
+
{ name: 'title', type: 'string', order: 2 },
|
|
67
|
+
{ name: 'footer', type: 'string', order: 3 },
|
|
68
|
+
{ name: 'landscape', type: 'boolean', order: 4, description: 'Default true.' },
|
|
69
|
+
{ name: 'outputPath', type: 'string', order: 5 }
|
|
70
|
+
],
|
|
71
|
+
outputSchema: [
|
|
72
|
+
{ name: 'filePath', type: 'string' }, { name: 'fileName', type: 'string' },
|
|
73
|
+
{ name: 'contentType', type: 'string' }, { name: 'bytes', type: 'number' }, { name: 'pages', type: 'number' }
|
|
74
|
+
],
|
|
75
|
+
|
|
76
|
+
execute: async function(ctx) {
|
|
77
|
+
var input = ctx.input || {};
|
|
78
|
+
checkFormatRequires('export-pages', 'pdf', pdf);
|
|
79
|
+
validate(input);
|
|
80
|
+
var name = (String(input.title || 'dashboard').replace(/[\\/:*?"<>|\u0000-\u001F]+/g, ' ').trim().slice(0, 80) || 'dashboard') + '.pdf';
|
|
81
|
+
var dir = process.env.XEPLR_ACTIONS_TMP_DIR || path.join(os.tmpdir(), 'xeplr-actions');
|
|
82
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
83
|
+
var filePath = input.outputPath || path.join(fs.mkdtempSync(path.join(dir, 'export-')), name);
|
|
84
|
+
var done = await pdf.renderPages(filePath, {
|
|
85
|
+
pages: input.pages, title: input.title, footer: input.footer, landscape: input.landscape !== false
|
|
86
|
+
});
|
|
87
|
+
return {
|
|
88
|
+
filePath: filePath, fileName: path.basename(filePath), contentType: 'application/pdf',
|
|
89
|
+
bytes: fs.statSync(filePath).size, pages: done.pages
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// export-table — rows to a CSV, Excel or PDF file.
|
|
2
|
+
//
|
|
3
|
+
// The rows arrive already DECIDED: which columns, in what order, under what
|
|
4
|
+
// heading, and for each cell both its raw value and the text a reader saw.
|
|
5
|
+
// This action does not know what a report or a pivot is; whoever calls it
|
|
6
|
+
// worked that out (in BI, report-engine's presentTable). Each format takes
|
|
7
|
+
// what it needs from a row:
|
|
8
|
+
//
|
|
9
|
+
// csv the raw values, unformatted — a file for another program
|
|
10
|
+
// xlsx the raw values as TYPED cells carrying each column's number format,
|
|
11
|
+
// so the sheet looks like the screen and still sums
|
|
12
|
+
// pdf the display text, exactly as shown
|
|
13
|
+
//
|
|
14
|
+
// Rows come inline (`rows`) or from an NDJSON file (`rowsFile`, one row per
|
|
15
|
+
// line — what streaming/spool writes), so a large result never has to be one
|
|
16
|
+
// array in memory. CSV and Excel stream; a PDF holds its rows (capped, 5,000 by
|
|
17
|
+
// default) because a page is laid out from them.
|
|
18
|
+
|
|
19
|
+
var fs = require('fs');
|
|
20
|
+
var os = require('os');
|
|
21
|
+
var path = require('path');
|
|
22
|
+
var readline = require('readline');
|
|
23
|
+
var writers = require('../../formats/writers');
|
|
24
|
+
var { checkFormatRequires } = require('../../formats');
|
|
25
|
+
|
|
26
|
+
function tmpDir() {
|
|
27
|
+
var dir = process.env.XEPLR_ACTIONS_TMP_DIR || path.join(os.tmpdir(), 'xeplr-actions');
|
|
28
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
29
|
+
return dir;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A file name from a title: what a person would recognise in their downloads. */
|
|
33
|
+
function fileNameFor(title, extension) {
|
|
34
|
+
var base = String(title || 'export').replace(/[\\/:*?"<>|\u0000-\u001F]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80) || 'export';
|
|
35
|
+
return base + '.' + extension;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function* rowsFrom(input) {
|
|
39
|
+
if (Array.isArray(input.rows)) { for (var i = 0; i < input.rows.length; i++) yield input.rows[i]; return; }
|
|
40
|
+
var lines = readline.createInterface({ input: fs.createReadStream(input.rowsFile), crlfDelay: Infinity });
|
|
41
|
+
for await (var line of lines) if (line.trim()) yield JSON.parse(line);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = {
|
|
45
|
+
name: 'export-table',
|
|
46
|
+
description: 'Write rows to a CSV, Excel (.xlsx) or PDF file. Excel cells stay numbers with their number format; ' +
|
|
47
|
+
'a PDF shows each cell as it was displayed.',
|
|
48
|
+
requires: [],
|
|
49
|
+
|
|
50
|
+
inputSchema: [
|
|
51
|
+
{ name: 'format', type: 'string', required: true, order: 1, enum: writers.SUPPORTED,
|
|
52
|
+
description: 'csv, xlsx or pdf.' },
|
|
53
|
+
{ name: 'document', type: 'object', required: true, order: 2,
|
|
54
|
+
description: '{ title?, subtitle?, footer?, columns: [{ header, kind?: number|date|text, numFmt?, align?, width? }] }. ' +
|
|
55
|
+
'numFmt is an Excel number format ("#,##0.00", "0.0%", "mmm yyyy").' },
|
|
56
|
+
{ name: 'rows', type: 'array', order: 3,
|
|
57
|
+
description: 'Rows: { values: [], display?: [], kind?: detail|subtotal|total }. Or give rowsFile.' },
|
|
58
|
+
{ name: 'rowsFile', type: 'string', order: 4,
|
|
59
|
+
description: 'An NDJSON file with one row per line, instead of rows.' },
|
|
60
|
+
{ name: 'outputPath', type: 'string', order: 5,
|
|
61
|
+
description: 'Where to write. Default: a new file in XEPLR_ACTIONS_TMP_DIR named after the title.' },
|
|
62
|
+
{ name: 'maxRows', type: 'number', order: 6,
|
|
63
|
+
description: 'PDF only: rows to draw before stopping with a note (default 5,000).' }
|
|
64
|
+
],
|
|
65
|
+
outputSchema: [
|
|
66
|
+
{ name: 'filePath', type: 'string' }, { name: 'fileName', type: 'string' },
|
|
67
|
+
{ name: 'contentType', type: 'string' }, { name: 'bytes', type: 'number' },
|
|
68
|
+
{ name: 'rows', type: 'number' }, { name: 'truncated', type: 'boolean' }
|
|
69
|
+
],
|
|
70
|
+
|
|
71
|
+
execute: async function(ctx) {
|
|
72
|
+
var input = ctx.input || {};
|
|
73
|
+
var format = String(input.format || '').toLowerCase();
|
|
74
|
+
var writer = writers.getWriter(format);
|
|
75
|
+
checkFormatRequires('export-table', format, writer);
|
|
76
|
+
var doc = Object.assign({}, input.document || {});
|
|
77
|
+
if (!Array.isArray(doc.columns) || !doc.columns.length) {
|
|
78
|
+
var bad = new Error('document.columns must list at least one column');
|
|
79
|
+
bad.status = 400;
|
|
80
|
+
throw bad;
|
|
81
|
+
}
|
|
82
|
+
if (!Array.isArray(input.rows) && !input.rowsFile) {
|
|
83
|
+
var none = new Error('Give rows, or a rowsFile');
|
|
84
|
+
none.status = 400;
|
|
85
|
+
throw none;
|
|
86
|
+
}
|
|
87
|
+
if (format === 'pdf' && input.maxRows) doc.maxRows = input.maxRows;
|
|
88
|
+
|
|
89
|
+
var info = writers.INFO[format];
|
|
90
|
+
var fileName = fileNameFor(doc.title, info.extension);
|
|
91
|
+
var filePath = input.outputPath || path.join(fs.mkdtempSync(path.join(tmpDir(), 'export-')), fileName);
|
|
92
|
+
var open = writer.open(filePath, doc);
|
|
93
|
+
var signal = ctx.signal;
|
|
94
|
+
try {
|
|
95
|
+
for await (var row of rowsFrom(input)) {
|
|
96
|
+
if (signal && signal.aborted) throw new Error('Stopped by request');
|
|
97
|
+
var more = await open.writeRow(row);
|
|
98
|
+
// Excel's own limit: the rest are only counted. A PDF keeps counting
|
|
99
|
+
// too, so its closing note can say how many were left out.
|
|
100
|
+
if (more === false && format === 'xlsx') { /* keep reading to count */ }
|
|
101
|
+
}
|
|
102
|
+
var done = await open.close();
|
|
103
|
+
return {
|
|
104
|
+
filePath: filePath,
|
|
105
|
+
fileName: path.basename(filePath),
|
|
106
|
+
contentType: info.contentType,
|
|
107
|
+
bytes: fs.statSync(filePath).size,
|
|
108
|
+
rows: done.rows,
|
|
109
|
+
truncated: done.truncated
|
|
110
|
+
};
|
|
111
|
+
} catch (err) {
|
|
112
|
+
try { fs.rmSync(filePath, { force: true }); } catch (e) { /* nothing to clean */ }
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
};
|
package/lib/builtins/index.js
CHANGED
|
@@ -61,5 +61,12 @@ module.exports = {
|
|
|
61
61
|
emailMove: require('./email/move'),
|
|
62
62
|
emailDelete: require('./email/delete'),
|
|
63
63
|
emailDownloadEmail: require('./email/download-email'),
|
|
64
|
-
emailDownloadAttachments: require('./email/download-attachments')
|
|
64
|
+
emailDownloadAttachments: require('./email/download-attachments'),
|
|
65
|
+
|
|
66
|
+
// Export — rows to CSV / Excel / PDF, and placed blocks to a PDF. The rows
|
|
67
|
+
// arrive already presented (columns, headings, raw values and display
|
|
68
|
+
// text); see export/table.js. Peers: fflate (xlsx), pdfkit and
|
|
69
|
+
// dejavu-fonts-ttf (pdf) — optional, required only by the format used.
|
|
70
|
+
exportTable: require('./export/table'),
|
|
71
|
+
exportPages: require('./export/pages')
|
|
65
72
|
};
|
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
|
+
};
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// .xlsx writer — our own, on fflate's streaming zip.
|
|
2
|
+
//
|
|
3
|
+
// Why not exceljs: it pulls a uuid with a published advisory, has not had a
|
|
4
|
+
// release since December 2024, and the override that silences it has to be
|
|
5
|
+
// repeated in every app that installs this package. Writing SpreadsheetML is
|
|
6
|
+
// the other half of what lib/formats/_xlsx-stream.js already reads, and a
|
|
7
|
+
// workbook of one styled sheet is a small, well-specified thing.
|
|
8
|
+
//
|
|
9
|
+
// WHAT IT WRITES: one sheet; an optional title and subtitle above the header;
|
|
10
|
+
// the header bold on a tint, frozen, with an autofilter; numbers and dates as
|
|
11
|
+
// REAL cells carrying their number format, so the reader can still sum and
|
|
12
|
+
// filter; subtotal rows bold, total rows bold on a tint; column widths from
|
|
13
|
+
// the header and the first rows.
|
|
14
|
+
//
|
|
15
|
+
// Streaming: rows go straight into the zip, which goes straight to disk,
|
|
16
|
+
// waiting on the file stream when it is full — a million rows costs the
|
|
17
|
+
// same memory as a hundred. Only the first SAMPLE_ROWS are held, to size the
|
|
18
|
+
// columns, because <cols> must come before the rows in the sheet.
|
|
19
|
+
|
|
20
|
+
var fs = require('fs');
|
|
21
|
+
|
|
22
|
+
var MAX_ROWS = 1048576; // Excel's own limit, header and title rows included
|
|
23
|
+
var SAMPLE_ROWS = 200;
|
|
24
|
+
var CHUNK = 1 << 20; // push to the zip in ~1 MB pieces
|
|
25
|
+
|
|
26
|
+
// XML 1.0 cannot carry most control characters even escaped; Excel refuses
|
|
27
|
+
// the file ("We found a problem with some content"). Dropped.
|
|
28
|
+
var INVALID_XML = /[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g;
|
|
29
|
+
function esc(value) {
|
|
30
|
+
return String(value).replace(INVALID_XML, '')
|
|
31
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 0 → A, 25 → Z, 26 → AA. */
|
|
35
|
+
function colName(i) {
|
|
36
|
+
var s = '';
|
|
37
|
+
for (var n = i + 1; n > 0; n = Math.floor((n - 1) / 26)) s = String.fromCharCode(65 + ((n - 1) % 26)) + s;
|
|
38
|
+
return s;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A Date, or an ISO date string, as an Excel serial — or null. */
|
|
42
|
+
function dateSerial(value) {
|
|
43
|
+
var d = value instanceof Date ? value : (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value) ? new Date(value) : null);
|
|
44
|
+
if (!d || isNaN(d.getTime())) return null;
|
|
45
|
+
return d.getTime() / 86400000 + 25569;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Fonts: 0 normal, 1 bold, 2 title. Fills: 0 none, 1 gray125 (both required
|
|
49
|
+
// by Excel at those positions), 2 header tint, 3 total tint.
|
|
50
|
+
var FONT = { normal: 0, bold: 1, title: 2 };
|
|
51
|
+
var FILL = { none: 0, header: 2, total: 3 };
|
|
52
|
+
|
|
53
|
+
function stylesXml(numFmts, xfs) {
|
|
54
|
+
var fmts = numFmts.map(function(code, i) { return '<numFmt numFmtId="' + (164 + i) + '" formatCode="' + esc(code) + '"/>'; });
|
|
55
|
+
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
|
|
56
|
+
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +
|
|
57
|
+
(fmts.length ? '<numFmts count="' + fmts.length + '">' + fmts.join('') + '</numFmts>' : '') +
|
|
58
|
+
'<fonts count="3"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font>' +
|
|
59
|
+
'<font><b/><sz val="14"/><name val="Calibri"/></font></fonts>' +
|
|
60
|
+
'<fills count="4"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill>' +
|
|
61
|
+
'<fill><patternFill patternType="solid"><fgColor rgb="FFF1F3F7"/><bgColor indexed="64"/></patternFill></fill>' +
|
|
62
|
+
'<fill><patternFill patternType="solid"><fgColor rgb="FFE8EBF2"/><bgColor indexed="64"/></patternFill></fill></fills>' +
|
|
63
|
+
'<borders count="2"><border><left/><right/><top/><bottom/><diagonal/></border>' +
|
|
64
|
+
'<border><left/><right/><top style="thin"><color rgb="FF9AA1AE"/></top><bottom/><diagonal/></border></borders>' +
|
|
65
|
+
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' +
|
|
66
|
+
'<cellXfs count="' + xfs.length + '">' + xfs.map(function(x) {
|
|
67
|
+
return '<xf numFmtId="' + x.numFmtId + '" fontId="' + x.fontId + '" fillId="' + x.fillId + '" borderId="' + x.borderId + '" xfId="0"' +
|
|
68
|
+
(x.numFmtId ? ' applyNumberFormat="1"' : '') + (x.fontId ? ' applyFont="1"' : '') + (x.fillId ? ' applyFill="1"' : '') +
|
|
69
|
+
(x.borderId ? ' applyBorder="1"' : '') + (x.align ? ' applyAlignment="1"><alignment horizontal="' + x.align + '"/></xf>' : '/>');
|
|
70
|
+
}).join('') + '</cellXfs>' +
|
|
71
|
+
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
var STATIC = {
|
|
75
|
+
'[Content_Types].xml': '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
|
|
76
|
+
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
|
|
77
|
+
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
|
|
78
|
+
'<Default Extension="xml" ContentType="application/xml"/>' +
|
|
79
|
+
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
|
|
80
|
+
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' +
|
|
81
|
+
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' +
|
|
82
|
+
'</Types>',
|
|
83
|
+
'_rels/.rels': '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
|
|
84
|
+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
|
85
|
+
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' +
|
|
86
|
+
'</Relationships>',
|
|
87
|
+
'xl/_rels/workbook.xml.rels': '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
|
|
88
|
+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
|
89
|
+
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>' +
|
|
90
|
+
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>' +
|
|
91
|
+
'</Relationships>'
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
function workbookXml(sheetName, filterRef) {
|
|
95
|
+
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
|
|
96
|
+
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' +
|
|
97
|
+
'<sheets><sheet name="' + esc(sheetName) + '" sheetId="1" r:id="rId1"/></sheets>' +
|
|
98
|
+
(filterRef ? '<definedNames><definedName name="_xlnm._FilterDatabase" localSheetId="0" hidden="1">' + esc("'" + sheetName.replace(/'/g, "''") + "'!" + filterRef) + '</definedName></definedNames>' : '') +
|
|
99
|
+
'</workbook>';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Excel refuses a sheet name over 31 characters or with any of : \ / ? * [ ] */
|
|
103
|
+
function sheetNameOf(title) {
|
|
104
|
+
var name = String(title || 'Sheet1').replace(/[:\\/?*[\]]/g, ' ').trim().slice(0, 31);
|
|
105
|
+
return name || 'Sheet1';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = {
|
|
109
|
+
requires: ['fflate'],
|
|
110
|
+
MAX_ROWS: MAX_ROWS,
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @param {string} filePath
|
|
114
|
+
* @param {object} doc - { title?, subtitle?, columns: [{ header, kind?: 'number'|'date'|'text'|'raw', numFmt?, width?, align? }] }
|
|
115
|
+
* kind 'number': the value is written as a number (numFmt applies);
|
|
116
|
+
* kind 'date': a Date or ISO string, written as a date serial (numFmt applies);
|
|
117
|
+
* anything else: a number stays a number, everything else is text.
|
|
118
|
+
* @returns {{ writeRow(row): Promise<boolean>, close(): Promise<{ rows, truncated }> }}
|
|
119
|
+
* row: { values: [], kind?: 'detail'|'subtotal'|'total' }. writeRow answers
|
|
120
|
+
* false once Excel's row limit is reached (the rest are counted, not written).
|
|
121
|
+
*/
|
|
122
|
+
open: function(filePath, doc) {
|
|
123
|
+
var fflate = require('fflate');
|
|
124
|
+
var columns = doc.columns || [];
|
|
125
|
+
var out = fs.createWriteStream(filePath);
|
|
126
|
+
var failed = null;
|
|
127
|
+
out.on('error', function(err) { failed = err; });
|
|
128
|
+
var zipDone;
|
|
129
|
+
var zipFinished = new Promise(function(resolve) { zipDone = resolve; });
|
|
130
|
+
var zip = new fflate.Zip(function(err, chunk, final) {
|
|
131
|
+
if (err) { failed = err; return; }
|
|
132
|
+
out.write(Buffer.from(chunk));
|
|
133
|
+
if (final) zipDone();
|
|
134
|
+
});
|
|
135
|
+
function addFile(name, text) {
|
|
136
|
+
var f = new fflate.ZipDeflate(name, { level: 6 });
|
|
137
|
+
zip.add(f);
|
|
138
|
+
f.push(fflate.strToU8(text), true);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Styles are collected as the rows use them and written at the end.
|
|
142
|
+
var numFmts = [];
|
|
143
|
+
var xfs = [{ numFmtId: 0, fontId: 0, fillId: 0, borderId: 0 }];
|
|
144
|
+
var xfIndex = { '0|0|0|0|': 0 };
|
|
145
|
+
function styleId(numFmt, fontId, fillId, borderId, align) {
|
|
146
|
+
var numFmtId = 0;
|
|
147
|
+
if (numFmt) {
|
|
148
|
+
var at = numFmts.indexOf(numFmt);
|
|
149
|
+
if (at < 0) { numFmts.push(numFmt); at = numFmts.length - 1; }
|
|
150
|
+
numFmtId = 164 + at;
|
|
151
|
+
}
|
|
152
|
+
var key = numFmtId + '|' + fontId + '|' + fillId + '|' + borderId + '|' + (align || '');
|
|
153
|
+
if (xfIndex[key] === undefined) {
|
|
154
|
+
xfs.push({ numFmtId: numFmtId, fontId: fontId, fillId: fillId, borderId: borderId, align: align || null });
|
|
155
|
+
xfIndex[key] = xfs.length - 1;
|
|
156
|
+
}
|
|
157
|
+
return xfIndex[key];
|
|
158
|
+
}
|
|
159
|
+
var LOOK = {
|
|
160
|
+
detail: { font: FONT.normal, fill: FILL.none, border: 0 },
|
|
161
|
+
subtotal: { font: FONT.bold, fill: FILL.none, border: 0 },
|
|
162
|
+
total: { font: FONT.bold, fill: FILL.total, border: 1 }
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
var sheet = new fflate.ZipDeflate('xl/worksheets/sheet1.xml', { level: 6 });
|
|
166
|
+
var buffered = '';
|
|
167
|
+
var rowNo = 0;
|
|
168
|
+
var written = 0; // data rows written
|
|
169
|
+
var offered = 0; // data rows asked for
|
|
170
|
+
var headerRow = 0;
|
|
171
|
+
var sample = [];
|
|
172
|
+
var started = false;
|
|
173
|
+
|
|
174
|
+
function cellXml(ref, value, column, look) {
|
|
175
|
+
if (value === null || value === undefined || value === '') return '';
|
|
176
|
+
var kind = column.kind;
|
|
177
|
+
var align = column.align === 'right' || column.align === 'center' ? column.align : null;
|
|
178
|
+
if (kind === 'date') {
|
|
179
|
+
var serial = dateSerial(value);
|
|
180
|
+
if (serial !== null) return '<c r="' + ref + '" s="' + styleId(column.numFmt || 'yyyy-mm-dd', look.font, look.fill, look.border, align) + '"><v>' + serial + '</v></c>';
|
|
181
|
+
}
|
|
182
|
+
var num = typeof value === 'number' ? value : (kind === 'number' && value !== '' && !isNaN(Number(value)) ? Number(value) : null);
|
|
183
|
+
if (num !== null && isFinite(num)) {
|
|
184
|
+
return '<c r="' + ref + '" s="' + styleId(kind === 'number' ? column.numFmt : null, look.font, look.fill, look.border, align) + '"><v>' + num + '</v></c>';
|
|
185
|
+
}
|
|
186
|
+
if (typeof value === 'boolean') return '<c r="' + ref + '" t="b" s="' + styleId(null, look.font, look.fill, look.border, align) + '"><v>' + (value ? 1 : 0) + '</v></c>';
|
|
187
|
+
return '<c r="' + ref + '" t="inlineStr" s="' + styleId(null, look.font, look.fill, look.border, align) + '"><is><t xml:space="preserve">' + esc(value) + '</t></is></c>';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function textRow(cells) { rowNo += 1; return '<row r="' + rowNo + '">' + cells + '</row>'; }
|
|
191
|
+
|
|
192
|
+
async function push(text, final) {
|
|
193
|
+
buffered += text;
|
|
194
|
+
if (buffered.length < CHUNK && !final) return;
|
|
195
|
+
sheet.push(fflate.strToU8(buffered), Boolean(final));
|
|
196
|
+
buffered = '';
|
|
197
|
+
if (failed) throw failed;
|
|
198
|
+
if (out.writableNeedDrain) await new Promise(function(r) { out.once('drain', r); });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// <cols> needs the widths, which need a look at the rows: written once
|
|
202
|
+
// the sample is full, or at close for a short table.
|
|
203
|
+
async function start() {
|
|
204
|
+
started = true;
|
|
205
|
+
var widths = columns.map(function(c, i) {
|
|
206
|
+
if (c.width) return c.width;
|
|
207
|
+
var longest = String(c.header || '').length;
|
|
208
|
+
// A date is as wide as its pattern, not as its JS string form.
|
|
209
|
+
if (c.kind === 'date') return Math.min(60, Math.max(10, longest, String(c.numFmt || 'yyyy-mm-dd').length) + 2);
|
|
210
|
+
sample.forEach(function(r) {
|
|
211
|
+
var shown = r.display ? r.display[i] : r.values[i];
|
|
212
|
+
if (shown !== null && shown !== undefined) longest = Math.max(longest, String(shown).length);
|
|
213
|
+
});
|
|
214
|
+
return Math.min(60, Math.max(8, longest + 2));
|
|
215
|
+
});
|
|
216
|
+
zip.add(sheet);
|
|
217
|
+
var head = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' +
|
|
218
|
+
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">';
|
|
219
|
+
var titleRows = (doc.title ? 1 : 0) + (doc.subtitle ? 1 : 0);
|
|
220
|
+
headerRow = titleRows + (titleRows ? 2 : 1);
|
|
221
|
+
head += '<sheetViews><sheetView workbookViewId="0"><pane ySplit="' + headerRow + '" topLeftCell="A' + (headerRow + 1) +
|
|
222
|
+
'" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>';
|
|
223
|
+
head += '<cols>' + widths.map(function(w, i) { return '<col min="' + (i + 1) + '" max="' + (i + 1) + '" width="' + w + '" customWidth="1"/>'; }).join('') + '</cols>';
|
|
224
|
+
head += '<sheetData>';
|
|
225
|
+
if (doc.title) head += textRow('<c r="A' + (rowNo + 1) + '" t="inlineStr" s="' + styleId(null, FONT.title, 0, 0) + '"><is><t xml:space="preserve">' + esc(doc.title) + '</t></is></c>');
|
|
226
|
+
if (doc.subtitle) head += textRow('<c r="A' + (rowNo + 1) + '" t="inlineStr"><is><t xml:space="preserve">' + esc(doc.subtitle) + '</t></is></c>');
|
|
227
|
+
if (titleRows) rowNo += 1; // a blank row between the title block and the table
|
|
228
|
+
head += textRow(columns.map(function(c, i) {
|
|
229
|
+
var align = c.align === 'right' || c.align === 'center' ? c.align : null;
|
|
230
|
+
return '<c r="' + colName(i) + (rowNo + 1) + '" t="inlineStr" s="' + styleId(null, FONT.bold, FILL.header, 0, align) + '"><is><t xml:space="preserve">' + esc(c.header == null ? '' : c.header) + '</t></is></c>';
|
|
231
|
+
}).join(''));
|
|
232
|
+
await push(head);
|
|
233
|
+
var held = sample;
|
|
234
|
+
sample = null;
|
|
235
|
+
for (var k = 0; k < held.length; k++) await emit(held[k]);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function emit(row) {
|
|
239
|
+
if (rowNo >= MAX_ROWS) return false;
|
|
240
|
+
var look = LOOK[row.kind] || LOOK.detail;
|
|
241
|
+
var r = rowNo + 1;
|
|
242
|
+
var cells = '';
|
|
243
|
+
var values = row.values || [];
|
|
244
|
+
for (var i = 0; i < columns.length; i++) cells += cellXml(colName(i) + r, values[i], columns[i], look);
|
|
245
|
+
written += 1;
|
|
246
|
+
await push(textRow(cells));
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
writeRow: async function(row) {
|
|
252
|
+
offered += 1;
|
|
253
|
+
if (!started) {
|
|
254
|
+
sample.push(row);
|
|
255
|
+
if (sample.length >= SAMPLE_ROWS) await start();
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
return emit(row);
|
|
259
|
+
},
|
|
260
|
+
close: async function() {
|
|
261
|
+
if (!started) await start();
|
|
262
|
+
var lastRow = Math.max(rowNo, headerRow);
|
|
263
|
+
var filterRef = columns.length ? 'A' + headerRow + ':' + colName(columns.length - 1) + lastRow : null;
|
|
264
|
+
await push('</sheetData>' + (filterRef ? '<autoFilter ref="' + filterRef + '"/>' : '') +
|
|
265
|
+
'<pageSetup orientation="' + (columns.length > 6 ? 'landscape' : 'portrait') + '"/></worksheet>', true);
|
|
266
|
+
var sheetName = sheetNameOf(doc.title);
|
|
267
|
+
Object.keys(STATIC).forEach(function(name) { addFile(name, STATIC[name]); });
|
|
268
|
+
addFile('xl/workbook.xml', workbookXml(sheetName, filterRef ? '$A$' + headerRow + ':$' + colName(columns.length - 1) + '$' + lastRow : null));
|
|
269
|
+
addFile('xl/styles.xml', stylesXml(numFmts, xfs));
|
|
270
|
+
zip.end();
|
|
271
|
+
await zipFinished;
|
|
272
|
+
await new Promise(function(resolve, reject) { out.end(function(err) { return err ? reject(err) : resolve(); }); });
|
|
273
|
+
if (failed) throw failed;
|
|
274
|
+
return { rows: written, truncated: offered > written };
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/actions",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.19",
|
|
4
4
|
"description": "Named-action registry with typed inputs and safe invocation — plus built-in actions for HTTP, DB, files, email, spawn",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|
|
@@ -16,7 +16,11 @@
|
|
|
16
16
|
"http",
|
|
17
17
|
"db",
|
|
18
18
|
"file",
|
|
19
|
-
"email"
|
|
19
|
+
"email",
|
|
20
|
+
"export",
|
|
21
|
+
"csv",
|
|
22
|
+
"xlsx",
|
|
23
|
+
"pdf"
|
|
20
24
|
],
|
|
21
25
|
"author": "xeplr",
|
|
22
26
|
"license": "MIT",
|
|
@@ -28,15 +32,17 @@
|
|
|
28
32
|
"access": "public"
|
|
29
33
|
},
|
|
30
34
|
"dependencies": {
|
|
31
|
-
"@xeplr/
|
|
32
|
-
"@xeplr/
|
|
35
|
+
"@xeplr/expression-handler": "^2.0.0",
|
|
36
|
+
"@xeplr/schema-handler": "^1.0.0"
|
|
33
37
|
},
|
|
34
38
|
"peerDependencies": {
|
|
35
39
|
"@duckdb/node-api": "1.5.5-r.3",
|
|
36
40
|
"@xeplr/email": "^1.0.0",
|
|
37
41
|
"@xeplr/utils": "^1.0.0",
|
|
38
42
|
"csv-parse": "^7.0.2",
|
|
43
|
+
"dejavu-fonts-ttf": "^2.37.3",
|
|
39
44
|
"exceljs": "^4.0.0",
|
|
45
|
+
"fflate": "^0.8.2",
|
|
40
46
|
"imapflow": "^1.0.0",
|
|
41
47
|
"knex": "^3.0.0",
|
|
42
48
|
"mailparser": "^3.0.0",
|
|
@@ -44,6 +50,7 @@
|
|
|
44
50
|
"mssql": "^11.0.0",
|
|
45
51
|
"mysql2": "^3.0.0",
|
|
46
52
|
"nodemailer": "^10.0.13",
|
|
53
|
+
"pdfkit": "^0.20.2",
|
|
47
54
|
"pg": "^8.0.0",
|
|
48
55
|
"saxes": "^6.0.0",
|
|
49
56
|
"ssh2-sftp-client": "^11.0.0",
|
|
@@ -97,15 +104,27 @@
|
|
|
97
104
|
},
|
|
98
105
|
"@xeplr/email": {
|
|
99
106
|
"optional": true
|
|
107
|
+
},
|
|
108
|
+
"fflate": {
|
|
109
|
+
"optional": true
|
|
110
|
+
},
|
|
111
|
+
"pdfkit": {
|
|
112
|
+
"optional": true
|
|
113
|
+
},
|
|
114
|
+
"dejavu-fonts-ttf": {
|
|
115
|
+
"optional": true
|
|
100
116
|
}
|
|
101
117
|
},
|
|
102
118
|
"devDependencies": {
|
|
103
119
|
"@duckdb/node-api": "^1.5.5-r.3",
|
|
104
120
|
"@xeplr/utils": "^1.0.0",
|
|
105
121
|
"csv-parse": "^7.0.2",
|
|
122
|
+
"dejavu-fonts-ttf": "^2.37.3",
|
|
106
123
|
"express": "^4.0.0",
|
|
124
|
+
"fflate": "^0.8.3",
|
|
107
125
|
"mssql": "^12.7.0",
|
|
108
126
|
"mysql2": "^3.23.1",
|
|
127
|
+
"pdfkit": "^0.20.2",
|
|
109
128
|
"pg": "^8.0.0",
|
|
110
129
|
"saxes": "^6.0.0",
|
|
111
130
|
"unzipper": "^0.12.0"
|