h17-sspdf 0.1.3 → 0.1.4
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 +21 -11
- package/cli.js +31 -7
- package/core/pdf-core.js +210 -3
- package/core/plugin-chart.js +40 -12
- package/core/plugin-registry.js +1 -1
- package/core/render-document.js +172 -2
- package/core/shapes.js +1 -1
- package/core/validate.js +13 -1
- package/fonts/crimson-text.js +1 -1
- package/fonts/fira-code.js +1 -1
- package/fonts/ibm-plex-sans.js +1 -1
- package/fonts/inter.js +1 -1
- package/fonts/jetbrains-mono.js +1 -1
- package/fonts/lato.js +1 -1
- package/fonts/libre-baskerville.js +1 -1
- package/fonts/lora.js +1 -1
- package/fonts/merriweather.js +1 -1
- package/fonts/montserrat.js +1 -1
- package/fonts/nunito.js +1 -1
- package/fonts/open-sans.js +1 -1
- package/fonts/oswald.js +1 -1
- package/fonts/playfair-display.js +1 -1
- package/fonts/pt-sans.js +1 -1
- package/fonts/raleway.js +1 -1
- package/fonts/roboto.js +1 -1
- package/fonts/source-code-pro.js +1 -1
- package/fonts/source-serif-4.js +1 -1
- package/fonts/work-sans.js +1 -1
- package/index.js +1 -1
- package/package.json +4 -15
- package/vendor/chart.js/LICENSE +9 -0
- package/vendor/chart.js/chart.umd.js +14 -0
- package/vendor/chartjs-node-canvas/LICENSE +21 -0
- package/vendor/chartjs-node-canvas/backgroundColourPlugin.js +21 -0
- package/vendor/chartjs-node-canvas/chartJSNodeCanvas.js +129 -0
- package/vendor/chartjs-node-canvas/chartJSNodeCanvasBase.js +90 -0
- package/vendor/chartjs-node-canvas/freshRequire.js +20 -0
- package/vendor/chartjs-node-canvas/index.js +12 -0
- package/vendor/jspdf/LICENSE +22 -0
- package/vendor/jspdf/jspdf.node.js +32458 -0
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ npm install h17-sspdf
|
|
|
22
22
|
|
|
23
23
|
## The problem it solves
|
|
24
24
|
|
|
25
|
-
Generating PDFs imperatively means tracking the cursor yourself. Every element you place shifts everything below it. Line wrapping, page breaks, font resets
|
|
25
|
+
Generating PDFs imperatively means tracking the cursor yourself. Every element you place shifts everything below it. Line wrapping, page breaks, font resets, all manual.
|
|
26
26
|
|
|
27
27
|
This engine inverts that. You describe *what* to render and *how it looks*. The cursor, the math, the page breaks happen automatically.
|
|
28
28
|
|
|
@@ -34,7 +34,7 @@ Every operation has a `type` and a `label`. The label maps to a style in the the
|
|
|
34
34
|
operation → label → theme style → layout → cursor advance → next operation
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
Page breaks happen automatically when content reaches the bottom margin. Style resets after every operation
|
|
37
|
+
Page breaks happen automatically when content reaches the bottom margin. Style resets after every operation, nothing leaks.
|
|
38
38
|
|
|
39
39
|
## Quick start
|
|
40
40
|
|
|
@@ -203,7 +203,7 @@ A repeating pattern of `row` + `text` pairs. The row carries the label/value, th
|
|
|
203
203
|
}
|
|
204
204
|
```
|
|
205
205
|
|
|
206
|
-
`xMm` and `maxWidthMm` indent it from the body column
|
|
206
|
+
`xMm` and `maxWidthMm` indent it from the body column, the indentation is in the source, not the theme.
|
|
207
207
|
|
|
208
208
|
#### Hidden text (ATS / search metadata)
|
|
209
209
|
|
|
@@ -273,8 +273,8 @@ Any operation accepts `xMm` and `maxWidthMm` to override the theme margins for t
|
|
|
273
273
|
|
|
274
274
|
### Page break control
|
|
275
275
|
|
|
276
|
-
- `keepWithNext: N`
|
|
277
|
-
- `block` with `keepTogether: true`
|
|
276
|
+
- `keepWithNext: N` - keep this operation on the same page as the next N operations
|
|
277
|
+
- `block` with `keepTogether: true` - all children stay on the same page
|
|
278
278
|
|
|
279
279
|
---
|
|
280
280
|
|
|
@@ -344,10 +344,12 @@ Then use `fontFamily: 'Inter'` in any label.
|
|
|
344
344
|
|
|
345
345
|
Renders any Chart.js configuration to a PNG and embeds it in the PDF.
|
|
346
346
|
|
|
347
|
-
###
|
|
347
|
+
### Requirements
|
|
348
|
+
|
|
349
|
+
The chart plugin requires the `canvas` npm package (native C++ addon). Chart.js and chartjs-node-canvas are vendored and ship with the engine.
|
|
348
350
|
|
|
349
351
|
```bash
|
|
350
|
-
npm install
|
|
352
|
+
npm install canvas
|
|
351
353
|
```
|
|
352
354
|
|
|
353
355
|
### Register
|
|
@@ -385,7 +387,7 @@ registerPlugin('chart', plugins.chart);
|
|
|
385
387
|
}
|
|
386
388
|
```
|
|
387
389
|
|
|
388
|
-
`data` and `options` are passed directly to Chart.js
|
|
390
|
+
`data` and `options` are passed directly to Chart.js, the plugin does not abstract the Chart.js API. `canvasWidth`/`canvasHeight` control render resolution (default 1600×800). `widthMm`/`heightMm` control the slot size in the PDF.
|
|
389
391
|
|
|
390
392
|
---
|
|
391
393
|
|
|
@@ -406,12 +408,20 @@ npx sspdf -s source.json -t theme.js -o output.pdf
|
|
|
406
408
|
## Constraints
|
|
407
409
|
|
|
408
410
|
- A4 only
|
|
409
|
-
- Single-line `row` cells
|
|
411
|
+
- Single-line `row` cells, no multi-line column pairs
|
|
410
412
|
- No `{{pages}}` total page count token
|
|
411
|
-
- The `
|
|
413
|
+
- The `canvas` npm package (native C++ addon) is the only runtime dependency, required for server-side chart rendering.
|
|
412
414
|
|
|
413
415
|
---
|
|
414
416
|
|
|
415
417
|
Hugo Palma, 2026
|
|
416
418
|
|
|
417
|
-
|
|
419
|
+
## Third-party
|
|
420
|
+
|
|
421
|
+
This project vendors the following MIT-licensed libraries:
|
|
422
|
+
|
|
423
|
+
- [jsPDF](https://github.com/parallax/jsPDF) - PDF generation. Copyright (c) 2010-2025 James Hall, yWorks GmbH.
|
|
424
|
+
- [Chart.js](https://github.com/chartjs/Chart.js) - Chart rendering. Copyright (c) 2014-2024 Chart.js Contributors.
|
|
425
|
+
- [chartjs-node-canvas](https://github.com/SeanSobey/ChartjsNodeCanvas) - Server-side Chart.js rendering. Copyright (c) 2018 Sean Sobey.
|
|
426
|
+
|
|
427
|
+
Full license texts are in `vendor/*/LICENSE`.
|
package/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require("fs");
|
|
4
4
|
const path = require("path");
|
|
5
|
-
const { renderDocument } = require("./
|
|
5
|
+
const { renderDocument, registerPlugin, plugins } = require("./index");
|
|
6
6
|
|
|
7
7
|
const EXAMPLES_THEMES_DIR = path.join(__dirname, "examples", "themes");
|
|
8
8
|
|
|
@@ -84,7 +84,7 @@ function readSource(sourcePath) {
|
|
|
84
84
|
function printHelp() {
|
|
85
85
|
const themes = listBuiltInThemes();
|
|
86
86
|
console.log(`
|
|
87
|
-
sspdf CLI
|
|
87
|
+
sspdf CLI - Render a source JSON + theme into a PDF.
|
|
88
88
|
|
|
89
89
|
Usage:
|
|
90
90
|
node cli.js --source <file.json> --theme <theme> --output <file.pdf>
|
|
@@ -105,7 +105,25 @@ Examples:
|
|
|
105
105
|
`.trim());
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
function
|
|
108
|
+
function collectChartOps(obj) {
|
|
109
|
+
const charts = [];
|
|
110
|
+
if (!obj || typeof obj !== "object") return charts;
|
|
111
|
+
if (Array.isArray(obj)) {
|
|
112
|
+
obj.forEach((item) => charts.push(...collectChartOps(item)));
|
|
113
|
+
} else {
|
|
114
|
+
if (obj.type === "chart") charts.push(obj);
|
|
115
|
+
for (const key of ["operations", "sections", "content", "items", "children"]) {
|
|
116
|
+
if (Array.isArray(obj[key])) charts.push(...collectChartOps(obj[key]));
|
|
117
|
+
}
|
|
118
|
+
if (obj.pageTemplates) {
|
|
119
|
+
if (Array.isArray(obj.pageTemplates.header)) charts.push(...collectChartOps(obj.pageTemplates.header));
|
|
120
|
+
if (Array.isArray(obj.pageTemplates.footer)) charts.push(...collectChartOps(obj.pageTemplates.footer));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return charts;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function main() {
|
|
109
127
|
const args = parseArgs(process.argv.slice(2));
|
|
110
128
|
if (args.help) {
|
|
111
129
|
printHelp();
|
|
@@ -121,15 +139,21 @@ function main() {
|
|
|
121
139
|
|
|
122
140
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
123
141
|
|
|
142
|
+
const chartOps = collectChartOps(source);
|
|
143
|
+
if (chartOps.length > 0) {
|
|
144
|
+
registerPlugin("chart", plugins.chart);
|
|
145
|
+
for (const op of chartOps) {
|
|
146
|
+
await plugins.chart.preRender(op);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
124
150
|
const result = renderDocument({ source, theme, outputPath });
|
|
125
151
|
|
|
126
152
|
console.log(`[OK] ${result.operationsCount} operations rendered`);
|
|
127
153
|
console.log(`[OK] ${outputPath}`);
|
|
128
154
|
}
|
|
129
155
|
|
|
130
|
-
|
|
131
|
-
main();
|
|
132
|
-
} catch (err) {
|
|
156
|
+
main().catch((err) => {
|
|
133
157
|
console.error(`[ERROR] ${err.message}`);
|
|
134
158
|
process.exit(1);
|
|
135
|
-
}
|
|
159
|
+
});
|
package/core/pdf-core.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
|
-
const { jsPDF } = require("jspdf");
|
|
2
|
+
const { jsPDF } = require("../vendor/jspdf/jspdf.node");
|
|
3
3
|
const { pxToMm, ptToMm, resolveLineHeightMm } = require("./units");
|
|
4
4
|
|
|
5
|
-
// Style math helpers
|
|
5
|
+
// Style math helpers, shared between core rendering and height estimation.
|
|
6
6
|
|
|
7
7
|
function getMarginTopMm(style) {
|
|
8
8
|
if (style.marginTopMm !== undefined) {
|
|
@@ -130,7 +130,7 @@ class PDFCore {
|
|
|
130
130
|
subject: meta.subject || "",
|
|
131
131
|
author: meta.author || "Hugo Palma",
|
|
132
132
|
keywords: meta.keywords || "",
|
|
133
|
-
creator: "SuperSimplePDF (github.com/hugopalma17/sspdf)
|
|
133
|
+
creator: "SuperSimplePDF (github.com/hugopalma17/sspdf) - built on jsPDF",
|
|
134
134
|
});
|
|
135
135
|
|
|
136
136
|
this.paintBackground();
|
|
@@ -628,6 +628,213 @@ class PDFCore {
|
|
|
628
628
|
return { y: drawY, endY };
|
|
629
629
|
}
|
|
630
630
|
|
|
631
|
+
/**
|
|
632
|
+
* Draw a table with header row, data rows, per-edge borders, and alt row shading.
|
|
633
|
+
* All values derived from style properties, no hardcoded constants.
|
|
634
|
+
*
|
|
635
|
+
* @param {object} payload
|
|
636
|
+
* @param {Array<{widthMm: number, align: string}>} payload.columns Resolved column definitions
|
|
637
|
+
* @param {string[]|null} payload.headers Header cell texts, or null for no header row
|
|
638
|
+
* @param {string[][]} payload.rows Data rows, each row is an array of cell strings
|
|
639
|
+
* @param {object} payload.cellStyle Resolved style for data cells
|
|
640
|
+
* @param {object|null} payload.headerStyle Resolved style for header cells
|
|
641
|
+
* @param {number} [payload.x] Left edge of table
|
|
642
|
+
* @param {number} [payload.maxWidth] Total table width
|
|
643
|
+
* @param {boolean} [payload.allowPageBreak]
|
|
644
|
+
* @returns {{ y: number, endY: number, rowCount: number }}
|
|
645
|
+
*/
|
|
646
|
+
drawTable(payload) {
|
|
647
|
+
const columns = payload.columns;
|
|
648
|
+
const headers = payload.headers || null;
|
|
649
|
+
const rows = payload.rows || [];
|
|
650
|
+
const cellStyle = payload.cellStyle || {};
|
|
651
|
+
const headerStyle = payload.headerStyle || null;
|
|
652
|
+
const x = payload.x !== undefined ? payload.x : this.marginLeftMm;
|
|
653
|
+
const allowPageBreak = payload.allowPageBreak !== false;
|
|
654
|
+
|
|
655
|
+
const marginTopMm = getMarginTopMm(cellStyle);
|
|
656
|
+
const marginBottomMm = getMarginBottomMm(cellStyle);
|
|
657
|
+
|
|
658
|
+
const cellPad = Number(cellStyle.cellPaddingMm) || 0;
|
|
659
|
+
const headerPad = headerStyle
|
|
660
|
+
? (Number(headerStyle.cellPaddingMm) || 0)
|
|
661
|
+
: cellPad;
|
|
662
|
+
|
|
663
|
+
// Column x positions
|
|
664
|
+
const colX = [];
|
|
665
|
+
let cx = x;
|
|
666
|
+
for (let c = 0; c < columns.length; c++) {
|
|
667
|
+
colX.push(cx);
|
|
668
|
+
cx += columns[c].widthMm;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Margin before table
|
|
672
|
+
if (allowPageBreak) {
|
|
673
|
+
const firstRowHeight = headers
|
|
674
|
+
? this._measureTableRowHeight(headers, columns, colX, headerStyle, headerPad)
|
|
675
|
+
: this._measureTableRowHeight(rows[0] || [], columns, colX, cellStyle, cellPad);
|
|
676
|
+
this.ensureSpace(marginTopMm + firstRowHeight);
|
|
677
|
+
}
|
|
678
|
+
const tableStartY = this.cursorY + marginTopMm;
|
|
679
|
+
this.cursorY = tableStartY;
|
|
680
|
+
|
|
681
|
+
// Draw header
|
|
682
|
+
if (headerStyle && headers) {
|
|
683
|
+
this._drawTableRow({
|
|
684
|
+
rowData: headers,
|
|
685
|
+
columns, colX,
|
|
686
|
+
style: headerStyle,
|
|
687
|
+
cellPadding: headerPad,
|
|
688
|
+
bgColor: this._resolveColor(headerStyle.backgroundColor),
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Draw data rows
|
|
693
|
+
for (let r = 0; r < rows.length; r++) {
|
|
694
|
+
const rowData = rows[r];
|
|
695
|
+
const rowHeight = this._measureTableRowHeight(rowData, columns, colX, cellStyle, cellPad);
|
|
696
|
+
|
|
697
|
+
// Page break check - re-draw header after break
|
|
698
|
+
if (allowPageBreak && this.cursorY + rowHeight > this.contentBottomY) {
|
|
699
|
+
this.addPage();
|
|
700
|
+
if (headerStyle && headers) {
|
|
701
|
+
this._drawTableRow({
|
|
702
|
+
rowData: headers,
|
|
703
|
+
columns, colX,
|
|
704
|
+
style: headerStyle,
|
|
705
|
+
cellPadding: headerPad,
|
|
706
|
+
bgColor: this._resolveColor(headerStyle.backgroundColor),
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Determine row background
|
|
712
|
+
const baseBg = this._resolveColor(cellStyle.backgroundColor);
|
|
713
|
+
const altBg = cellStyle.altRowColor && Array.isArray(cellStyle.altRowColor) && cellStyle.altRowColor.length === 3
|
|
714
|
+
? cellStyle.altRowColor
|
|
715
|
+
: null;
|
|
716
|
+
const bgColor = (altBg && r % 2 === 1) ? altBg : baseBg;
|
|
717
|
+
|
|
718
|
+
this._drawTableRow({
|
|
719
|
+
rowData,
|
|
720
|
+
columns, colX,
|
|
721
|
+
style: cellStyle,
|
|
722
|
+
cellPadding: cellPad,
|
|
723
|
+
bgColor,
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const endY = this.cursorY;
|
|
728
|
+
this.cursorY = endY + marginBottomMm;
|
|
729
|
+
|
|
730
|
+
this.lastDrawnBounds = {
|
|
731
|
+
topY: tableStartY,
|
|
732
|
+
bottomY: endY,
|
|
733
|
+
leftX: x,
|
|
734
|
+
rightX: colX.length > 0 ? colX[colX.length - 1] + columns[columns.length - 1].widthMm : x,
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
return { y: tableStartY, endY, rowCount: rows.length };
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Measure the height of a single table row.
|
|
742
|
+
* @private
|
|
743
|
+
*/
|
|
744
|
+
_measureTableRowHeight(rowData, columns, colX, style, cellPadding) {
|
|
745
|
+
const lineHeightMm = style.lineHeightMm || resolveLineHeightMm(Number(style.fontSize), style.lineHeight);
|
|
746
|
+
let maxLines = 1;
|
|
747
|
+
for (let c = 0; c < columns.length; c++) {
|
|
748
|
+
const innerWidth = columns[c].widthMm - (cellPadding * 2);
|
|
749
|
+
if (innerWidth <= 0) continue;
|
|
750
|
+
const text = String((rowData && rowData[c]) || "");
|
|
751
|
+
const lines = this.measureWrappedLines(text, innerWidth, style);
|
|
752
|
+
if (lines.length > maxLines) maxLines = lines.length;
|
|
753
|
+
}
|
|
754
|
+
return cellPadding + (maxLines * lineHeightMm) + cellPadding;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Draw a single table row (background, text, borders).
|
|
759
|
+
* @private
|
|
760
|
+
*/
|
|
761
|
+
_drawTableRow(payload) {
|
|
762
|
+
const { rowData, columns, colX, style, cellPadding, bgColor } = payload;
|
|
763
|
+
const rowY = this.cursorY;
|
|
764
|
+
const lineHeightMm = style.lineHeightMm || resolveLineHeightMm(Number(style.fontSize), style.lineHeight);
|
|
765
|
+
|
|
766
|
+
// Measure row height
|
|
767
|
+
let maxLines = 1;
|
|
768
|
+
const cellLines = [];
|
|
769
|
+
for (let c = 0; c < columns.length; c++) {
|
|
770
|
+
const innerWidth = columns[c].widthMm - (cellPadding * 2);
|
|
771
|
+
const text = String((rowData && rowData[c]) || "");
|
|
772
|
+
const lines = innerWidth > 0 ? this.measureWrappedLines(text, innerWidth, style) : [text];
|
|
773
|
+
cellLines.push(lines);
|
|
774
|
+
if (lines.length > maxLines) maxLines = lines.length;
|
|
775
|
+
}
|
|
776
|
+
const rowHeight = cellPadding + (maxLines * lineHeightMm) + cellPadding;
|
|
777
|
+
|
|
778
|
+
// Draw cell backgrounds
|
|
779
|
+
if (bgColor) {
|
|
780
|
+
this.doc.setFillColor(...bgColor);
|
|
781
|
+
for (let c = 0; c < columns.length; c++) {
|
|
782
|
+
this.doc.rect(colX[c], rowY, columns[c].widthMm, rowHeight, "F");
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Draw cell text
|
|
787
|
+
this.applyTextStyle(style);
|
|
788
|
+
const baselineOffset = this._getBaselineOffsetMm(Number(style.fontSize));
|
|
789
|
+
this.doc.setLineHeightFactor(Number(style.lineHeight));
|
|
790
|
+
for (let c = 0; c < columns.length; c++) {
|
|
791
|
+
const innerWidth = columns[c].widthMm - (cellPadding * 2);
|
|
792
|
+
if (innerWidth <= 0) continue;
|
|
793
|
+
const textX = colX[c] + cellPadding;
|
|
794
|
+
const textY = rowY + cellPadding + baselineOffset;
|
|
795
|
+
const align = columns[c].align || "left";
|
|
796
|
+
this._drawTextLines(cellLines[c], {
|
|
797
|
+
x: textX,
|
|
798
|
+
y: textY,
|
|
799
|
+
maxWidth: innerWidth,
|
|
800
|
+
align,
|
|
801
|
+
lineHeightMm,
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Draw cell borders
|
|
806
|
+
for (let c = 0; c < columns.length; c++) {
|
|
807
|
+
this._drawTableCellBorders(style, colX[c], rowY, columns[c].widthMm, rowHeight);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
this.applyDefaultRenderState();
|
|
811
|
+
this.cursorY = rowY + rowHeight;
|
|
812
|
+
return rowHeight;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Draw per-edge borders for a single table cell.
|
|
817
|
+
* @private
|
|
818
|
+
*/
|
|
819
|
+
_drawTableCellBorders(style, cellX, cellY, cellWidth, cellHeight) {
|
|
820
|
+
const baseBorderColor = this._resolveColor(style.borderColor, [200, 200, 200]);
|
|
821
|
+
const edges = [
|
|
822
|
+
{ widthProp: "borderTopMm", colorProp: "borderTopColor", x1: cellX, y1: cellY, x2: cellX + cellWidth, y2: cellY },
|
|
823
|
+
{ widthProp: "borderBottomMm", colorProp: "borderBottomColor", x1: cellX, y1: cellY + cellHeight, x2: cellX + cellWidth, y2: cellY + cellHeight },
|
|
824
|
+
{ widthProp: "borderLeftMm", colorProp: "borderLeftColor", x1: cellX, y1: cellY, x2: cellX, y2: cellY + cellHeight },
|
|
825
|
+
{ widthProp: "borderRightMm", colorProp: "borderRightColor", x1: cellX + cellWidth, y1: cellY, x2: cellX + cellWidth, y2: cellY + cellHeight },
|
|
826
|
+
];
|
|
827
|
+
|
|
828
|
+
for (const edge of edges) {
|
|
829
|
+
const width = Number(style[edge.widthProp]) || 0;
|
|
830
|
+
if (width <= 0) continue;
|
|
831
|
+
const color = this._resolveColor(style[edge.colorProp], baseBorderColor);
|
|
832
|
+
this.doc.setDrawColor(...color);
|
|
833
|
+
this.doc.setLineWidth(width);
|
|
834
|
+
this.doc.line(edge.x1, edge.y1, edge.x2, edge.y2);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
631
838
|
/**
|
|
632
839
|
* Draw visually hidden text (ATS tags, keywords) without moving cursor.
|
|
633
840
|
* @param {object} payload
|
package/core/plugin-chart.js
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* Renders any Chart.js configuration server-side via chartjs-node-canvas
|
|
7
7
|
* and embeds the result as a PNG image in the PDF.
|
|
8
8
|
*
|
|
9
|
-
* Requires
|
|
10
|
-
* npm install
|
|
9
|
+
* Requires the `canvas` npm package (native C++ addon):
|
|
10
|
+
* npm install canvas
|
|
11
11
|
*
|
|
12
12
|
* Registration:
|
|
13
13
|
* const { registerPlugin, plugins } = require('h17-sspdf');
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
* - canvasWidth/canvasHeight control render resolution (default 1600x800).
|
|
46
46
|
* Higher values = sharper chart. widthMm/heightMm control the PDF slot size.
|
|
47
47
|
* - responsive: false and animation: false are injected automatically.
|
|
48
|
-
* - Pass any valid Chart.js config in data and options
|
|
48
|
+
* - Pass any valid Chart.js config in data and options, the plugin does not
|
|
49
49
|
* modify or abstract it.
|
|
50
50
|
*/
|
|
51
51
|
|
|
@@ -54,10 +54,10 @@ let _ChartJSNodeCanvas = null;
|
|
|
54
54
|
function getCanvas() {
|
|
55
55
|
if (!_ChartJSNodeCanvas) {
|
|
56
56
|
try {
|
|
57
|
-
_ChartJSNodeCanvas = require('chartjs-node-canvas').ChartJSNodeCanvas;
|
|
57
|
+
_ChartJSNodeCanvas = require('../vendor/chartjs-node-canvas').ChartJSNodeCanvas;
|
|
58
58
|
} catch {
|
|
59
59
|
throw new Error(
|
|
60
|
-
'chart plugin requires
|
|
60
|
+
'chart plugin requires the canvas package - run: npm install canvas'
|
|
61
61
|
);
|
|
62
62
|
}
|
|
63
63
|
}
|
|
@@ -70,6 +70,32 @@ function getCanvas() {
|
|
|
70
70
|
* @param {object} operation - the chart operation object (mutated in place)
|
|
71
71
|
* @returns {Promise<void>}
|
|
72
72
|
*/
|
|
73
|
+
/**
|
|
74
|
+
* Walk an options object and convert callback template strings like "{{v}}%"
|
|
75
|
+
* into real functions. This lets JSON sources define simple tick formatters
|
|
76
|
+
* without requiring executable code in the source file.
|
|
77
|
+
*
|
|
78
|
+
* Supported token: {{v}} - replaced with the callback's first argument (value).
|
|
79
|
+
*/
|
|
80
|
+
function resolveCallbackTemplates(obj) {
|
|
81
|
+
if (!obj || typeof obj !== 'object') return obj;
|
|
82
|
+
if (Array.isArray(obj)) {
|
|
83
|
+
for (let i = 0; i < obj.length; i++) {
|
|
84
|
+
obj[i] = resolveCallbackTemplates(obj[i]);
|
|
85
|
+
}
|
|
86
|
+
return obj;
|
|
87
|
+
}
|
|
88
|
+
for (const key of Object.keys(obj)) {
|
|
89
|
+
if (key === 'callback' && typeof obj[key] === 'string' && obj[key].includes('{{v}}')) {
|
|
90
|
+
const template = obj[key];
|
|
91
|
+
obj[key] = function (value) { return template.replace(/\{\{v\}\}/g, String(value)); };
|
|
92
|
+
} else {
|
|
93
|
+
obj[key] = resolveCallbackTemplates(obj[key]);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return obj;
|
|
97
|
+
}
|
|
98
|
+
|
|
73
99
|
async function preRender(operation) {
|
|
74
100
|
const ChartJSNodeCanvas = getCanvas();
|
|
75
101
|
const canvasW = operation.canvasWidth || 1600;
|
|
@@ -81,14 +107,16 @@ async function preRender(operation) {
|
|
|
81
107
|
backgroundColour: 'transparent',
|
|
82
108
|
});
|
|
83
109
|
|
|
110
|
+
const options = resolveCallbackTemplates({
|
|
111
|
+
...(operation.options || {}),
|
|
112
|
+
responsive: false,
|
|
113
|
+
animation: false,
|
|
114
|
+
});
|
|
115
|
+
|
|
84
116
|
operation._buf = await canvas.renderToBuffer({
|
|
85
117
|
type: operation.chartType || 'bar',
|
|
86
118
|
data: operation.data || { labels: [], datasets: [] },
|
|
87
|
-
options
|
|
88
|
-
...(operation.options || {}),
|
|
89
|
-
responsive: false,
|
|
90
|
-
animation: false,
|
|
91
|
-
},
|
|
119
|
+
options,
|
|
92
120
|
});
|
|
93
121
|
}
|
|
94
122
|
|
|
@@ -100,7 +128,7 @@ module.exports = {
|
|
|
100
128
|
|
|
101
129
|
if (!operation._buf) {
|
|
102
130
|
throw new Error(
|
|
103
|
-
'chart plugin: operation._buf is missing
|
|
131
|
+
'chart plugin: operation._buf is missing - call plugin.preRender(operation) before renderDocument()'
|
|
104
132
|
);
|
|
105
133
|
}
|
|
106
134
|
|
|
@@ -120,7 +148,7 @@ module.exports = {
|
|
|
120
148
|
throw new Error('chart operation requires chartType (e.g. "bar", "line", "doughnut")');
|
|
121
149
|
}
|
|
122
150
|
if (!operation.data) {
|
|
123
|
-
throw new Error('chart operation requires data
|
|
151
|
+
throw new Error('chart operation requires data - pass a Chart.js data config object');
|
|
124
152
|
}
|
|
125
153
|
},
|
|
126
154
|
};
|
package/core/plugin-registry.js
CHANGED